diff --git a/backend/build.gradle b/backend/build.gradle index cf6441d2e..7eba3dee0 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -20,46 +20,52 @@ ext { } dependencies { - //spring + // Spring implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-webflux' implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' - implementation 'org.projectlombok:lombok:1.18.18' // Security implementation 'org.springframework.boot:spring-boot-starter-security' testImplementation 'org.springframework.security:spring-security-test' - //database + // Database runtimeOnly 'mysql:mysql-connector-java' runtimeOnly 'com.h2database:h2' - //flyway + + // Flyway implementation 'org.flywaydb:flyway-core:6.4.2' + // Test testImplementation 'io.rest-assured:rest-assured:3.3.0' testImplementation 'org.springframework.boot:spring-boot-starter-test' - //restdocs + // Restdocs asciidoctor 'org.springframework.restdocs:spring-restdocs-asciidoctor' testImplementation 'org.springframework.restdocs:spring-restdocs-restassured' - //jwt + // Jwt implementation 'io.jsonwebtoken:jjwt:0.9.1' - //svg + // SvgToPng implementation 'org.apache.xmlgraphics:batik-all:1.12' implementation 'org.apache.xmlgraphics:xmlgraphics-commons:2.4' implementation 'xml-apis:xml-apis:1.4.01' implementation 'xml-apis:xml-apis-ext:1.3.04' - //cryptor + // Cryptor implementation 'commons-codec:commons-codec:1.15' - // lombok + // Lombok compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' + + // Mock Web Server + testImplementation 'com.squareup.okhttp3:okhttp:4.0.1' + testImplementation 'com.squareup.okhttp3:mockwebserver:4.0.1' } test { @@ -105,7 +111,7 @@ jacocoTestCoverageVerification { excludes = ["**.exception.**", "**.ControllerAdvice", "**.*ErrorResponse", "**.ValidatorMessage", "**.ZzimkkongApplication", "**.*TimeConverter", "**.DataLoader", "**.*Config", "**.LoginInterceptor", "**.AuthenticationPrincipalArgumentResolver", - "**.slack.**", "**.Slack*"] + "**.slack.**", "**.Slack*", "**.datasource.**"] //todo: 패키지 분리하면 더 멋있게 해보겠슴,, limit { @@ -139,6 +145,6 @@ sonarqube { property 'sonar.coverage.jacoco.xmlReportPaths', 'build/reports/jacoco/test/jacocoTestReport.xml' property 'sonar.java.binaries', 'build/classes' property 'sonar.test.inclusions', '**/*Test.java' - property 'sonar.exclusions', '**/*Doc*.java, **/resources/**' + property 'sonar.exclusions', '**/*Doc*.java, **/resources/**, **/config/datasource/**' } } diff --git a/backend/src/docs/asciidoc/member.adoc b/backend/src/docs/asciidoc/member.adoc index d03eca9d2..0520b584a 100644 --- a/backend/src/docs/asciidoc/member.adoc +++ b/backend/src/docs/asciidoc/member.adoc @@ -25,3 +25,57 @@ include::{snippets}/member/token/success/http-request.adoc[] include::{snippets}/member/token/success/http-response.adoc[] ==== Fail Response include::{snippets}/member/token/fail/http-response.adoc[] + +=== 멤버 구글 이메일 반환 +==== Request +include::{snippets}/member/get/oauth/GOOGLE/http-request.adoc[] +==== Response +include::{snippets}/member/get/oauth/GOOGLE/http-response.adoc[] + +=== 멤버 깃헙 이메일 반환 +==== Request +include::{snippets}/member/get/oauth/GITHUB/http-request.adoc[] +==== Response +include::{snippets}/member/get/oauth/GITHUB/http-response.adoc[] + +=== 멤버 구글 회원가입 +==== Request +include::{snippets}/member/post/oauth/GOOGLE/http-request.adoc[] +==== Response +include::{snippets}/member/post/oauth/GOOGLE/http-response.adoc[] + +=== 멤버 깃헙 회원가입 +==== Request +include::{snippets}/member/post/oauth/GITHUB/http-request.adoc[] +==== Response +include::{snippets}/member/post/oauth/GITHUB/http-response.adoc[] + +=== 멤버 구글 로그인 +==== Request +include::{snippets}/member/login/oauth/GOOGLE/http-request.adoc[] +==== Response +include::{snippets}/member/login/oauth/GOOGLE/http-response.adoc[] + +=== 멤버 깃헙 로그인 +==== Request +include::{snippets}/member/login/oauth/GITHUB/http-request.adoc[] +==== Response +include::{snippets}/member/login/oauth/GITHUB/http-response.adoc[] + +=== 멤버 정보 조회 +==== Request +include::{snippets}/member/myinfo/get/http-request.adoc[] +==== Response +include::{snippets}/member/myinfo/get/http-response.adoc[] + +=== 멤버 정보 수정 +==== Request +include::{snippets}/member/myinfo/put/http-request.adoc[] +==== Response +include::{snippets}/member/myinfo/put/http-response.adoc[] + +=== 회원 탈퇴 +==== Request +include::{snippets}/member/myinfo/delete/http-request.adoc[] +==== Response +include::{snippets}/member/myinfo/delete/http-response.adoc[] diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/DataLoader.java b/backend/src/main/java/com/woowacourse/zzimkkong/DataLoader.java index 5ff18e8af..7366609ab 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/DataLoader.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/DataLoader.java @@ -39,7 +39,9 @@ public void run(String... args) { String meetingRoomColor = "#FFE3AC"; Member pobi = members.save( - new Member("pobi@woowa.com", "test1234", "woowacourse") + new Member("pobi@woowa.com", + "$2a$10$c3BysogWR4hnexYx60/r/e3lEUIbSs4zhW6kuX4UW733MW5/NmbW.", // test1234 입니다. + "woowacourse") ); Map luther = maps.save( @@ -51,13 +53,13 @@ public void run(String... args) { ); Setting defaultSetting = Setting.builder() - .availableStartTime(LocalTime.of(0, 0)) - .availableEndTime(LocalTime.of(23, 59)) + .availableStartTime(LocalTime.of(9, 0)) + .availableEndTime(LocalTime.of(22, 00)) .reservationTimeUnit(10) .reservationMinimumTimeUnit(10) - .reservationMaximumTimeUnit(1440) + .reservationMaximumTimeUnit(700) .reservationEnable(true) - .enabledDayOfWeek(null) + .enabledDayOfWeek("monday,tuesday,wednesday,thursday,friday,saturday,sunday") .build(); Space be = Space.builder() @@ -175,6 +177,7 @@ public void run(String... args) { Reservation reservationBackEndTargetDate0To1 = Reservation.builder() .startTime(targetDate.atStartOfDay()) .endTime(targetDate.atTime(1, 0, 0)) + .date(targetDate) .description("찜꽁 1차 회의") .userName("찜꽁") .password("1234") @@ -184,6 +187,7 @@ public void run(String... args) { Reservation reservationBackEndTargetDate13To14 = Reservation.builder() .startTime(targetDate.atTime(13, 0, 0)) .endTime(targetDate.atTime(14, 0, 0)) + .date(targetDate) .description("찜꽁 2차 회의") .userName("찜꽁") .password("1234") @@ -193,6 +197,7 @@ public void run(String... args) { Reservation reservationBackEndTargetDate18To23 = Reservation.builder() .startTime(targetDate.atTime(18, 0, 0)) .endTime(targetDate.atTime(23, 59, 59)) + .date(targetDate) .description("찜꽁 3차 회의") .userName("찜꽁") .password("6789") @@ -202,6 +207,7 @@ public void run(String... args) { Reservation reservationBackEndTheDayAfterTargetDate = Reservation.builder() .startTime(targetDate.plusDays(1L).atStartOfDay()) .endTime(targetDate.plusDays(1L).atTime(1, 0, 0)) + .date(targetDate) .description("찜꽁 4차 회의") .userName("찜꽁") .password("1234") @@ -211,6 +217,7 @@ public void run(String... args) { Reservation reservationFrontEnd1TargetDate0to1 = Reservation.builder() .startTime(targetDate.atStartOfDay()) .endTime(targetDate.atTime(1, 0, 0)) + .date(targetDate) .description("찜꽁 5차 회의") .userName("찜꽁") .password("1234") diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/config/AuthenticationPrincipalConfig.java b/backend/src/main/java/com/woowacourse/zzimkkong/config/AuthenticationPrincipalConfig.java index d1d45f2ae..ac98973fa 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/config/AuthenticationPrincipalConfig.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/config/AuthenticationPrincipalConfig.java @@ -26,11 +26,29 @@ public void addArgumentResolvers(List argumentResolvers) { @Override public void addInterceptors(InterceptorRegistry registry) { List pathsToAdd = List.of( - "/api/members/token", + "/api/managers/token", "/api/managers/**" ); + List pathsToExclude = List.of( + //manager join + "/api/managers", + "/api/managers/GOOGLE", + "/api/managers/GITHUB", + "/api/managers/google", + "/api/managers/github", + "/api/managers/oauth", + + //manager login + "/api/managers/login/token", + "/api/managers/GOOGLE/login/token", + "/api/managers/GITHUB/login/token", + "/api/managers/google/login/token", + "/api/managers/github/login/token" + ); + registry.addInterceptor(loginInterceptor) - .addPathPatterns(pathsToAdd); + .addPathPatterns(pathsToAdd) + .excludePathPatterns(pathsToExclude); } } diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/config/OauthConfig.java b/backend/src/main/java/com/woowacourse/zzimkkong/config/OauthConfig.java new file mode 100644 index 000000000..e599ded4b --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/config/OauthConfig.java @@ -0,0 +1,14 @@ +package com.woowacourse.zzimkkong.config; + +import com.woowacourse.zzimkkong.infrastructure.oauth.StringToOauthProviderConverter; +import org.springframework.context.annotation.Configuration; +import org.springframework.format.FormatterRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class OauthConfig implements WebMvcConfigurer { + @Override + public void addFormatters(FormatterRegistry registry) { + registry.addConverter(new StringToOauthProviderConverter()); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/config/OauthGithubConfig.java b/backend/src/main/java/com/woowacourse/zzimkkong/config/OauthGithubConfig.java new file mode 100644 index 000000000..e95e9d085 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/config/OauthGithubConfig.java @@ -0,0 +1,42 @@ +package com.woowacourse.zzimkkong.config; + +import com.woowacourse.zzimkkong.infrastructure.oauth.GithubRequester; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.context.annotation.PropertySource; + +@Configuration +@PropertySource("classpath:config/oauth.properties") +public class OauthGithubConfig { + @Bean(name = "githubRequester") + @Profile({"prod"}) + public GithubRequester githubRequesterProd( + @Value("${github.client-id.prod}") final String clientId, + @Value("${github.secret-id.prod}") final String secretId, + @Value("${github.url.oauth-login}") final String githubOauthUrl, + @Value("${github.url.open-api}") final String githubOpenApiUrl) { + return new GithubRequester(clientId, secretId, githubOauthUrl, githubOpenApiUrl); + } + + @Bean(name = "githubRequester") + @Profile({"dev"}) + public GithubRequester githubRequesterDev( + @Value("${github.client-id.dev}") final String clientId, + @Value("${github.secret-id.dev}") final String secretId, + @Value("${github.url.oauth-login}") final String githubOauthUrl, + @Value("${github.url.open-api}") final String githubOpenApiUrl) { + return new GithubRequester(clientId, secretId, githubOauthUrl, githubOpenApiUrl); + } + + @Bean(name = "githubRequester") + @Profile({"local", "test"}) + public GithubRequester githubRequesterLocalTest( + @Value("${github.client-id.local}") final String clientId, + @Value("${github.secret-id.local}") final String secretId, + @Value("${github.url.oauth-login}") final String githubOauthUrl, + @Value("${github.url.open-api}") final String githubOpenApiUrl) { + return new GithubRequester(clientId, secretId, githubOauthUrl, githubOpenApiUrl); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/config/datasource/CircularList.java b/backend/src/main/java/com/woowacourse/zzimkkong/config/datasource/CircularList.java new file mode 100644 index 000000000..796dc6497 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/config/datasource/CircularList.java @@ -0,0 +1,19 @@ +package com.woowacourse.zzimkkong.config.datasource; + +import java.util.List; + +public class CircularList { + private final List list; + private Integer counter = 0; + + public CircularList(List list) { + this.list = list; + } + + public T getOne() { + if (counter + 1 >= list.size()) { + counter = -1; + } + return list.get(++counter); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/config/datasource/CustomDataSourceConfig.java b/backend/src/main/java/com/woowacourse/zzimkkong/config/datasource/CustomDataSourceConfig.java new file mode 100644 index 000000000..1a8edc61f --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/config/datasource/CustomDataSourceConfig.java @@ -0,0 +1,93 @@ +package com.woowacourse.zzimkkong.config.datasource; + +import com.woowacourse.zzimkkong.exception.infrastructure.NoMasterDataSourceException; +import com.zaxxer.hikari.HikariDataSource; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.boot.autoconfigure.orm.jpa.JpaProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.jdbc.DataSourceBuilder; +import org.springframework.boot.orm.jpa.EntityManagerFactoryBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.jdbc.datasource.LazyConnectionDataSourceProxy; +import org.springframework.orm.jpa.JpaTransactionManager; +import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; +import org.springframework.orm.jpa.vendor.AbstractJpaVendorAdapter; +import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter; +import org.springframework.transaction.PlatformTransactionManager; + +import javax.persistence.EntityManagerFactory; +import javax.sql.DataSource; +import java.util.*; +import java.util.stream.Collectors; + +@Configuration +@Profile("prod") +public class CustomDataSourceConfig { + public static final String MASTER = "master"; + public static final String SLAVE = "slave"; + private static final String PACKAGE_PATH = "com.woowacourse.zzimkkong"; + private final List hikariDataSources; + private final JpaProperties jpaProperties; + + public CustomDataSourceConfig(final List hikariDataSources, final JpaProperties jpaProperties) { + this.hikariDataSources = hikariDataSources; + this.jpaProperties = jpaProperties; + } + + @Bean + public DataSource dataSource() { + return new LazyConnectionDataSourceProxy(routingDataSource()); + } + + @Bean + public DataSource routingDataSource() { + final DataSource master = createMasterDataSource(); + final Map slaves = createSlaveDataSources(); + slaves.put(MASTER, master); + + ReplicationRoutingDataSource replicationRoutingDataSource = new ReplicationRoutingDataSource(); + replicationRoutingDataSource.setDefaultTargetDataSource(master); + replicationRoutingDataSource.setTargetDataSources(slaves); + return replicationRoutingDataSource; + } + + private DataSource createMasterDataSource() { + return hikariDataSources.stream() + .filter(dataSource -> dataSource.getPoolName().startsWith(MASTER)) + .findFirst() + .orElseThrow(NoMasterDataSourceException::new); + } + + private Map createSlaveDataSources() { + final List slaveDataSources = hikariDataSources.stream() + .filter(datasource -> Objects.nonNull(datasource.getPoolName()) && datasource.getPoolName().startsWith(SLAVE)) + .collect(Collectors.toList()); + + final Map result = new HashMap<>(); + for (final HikariDataSource slaveDataSource : slaveDataSources) { + result.put(slaveDataSource.getPoolName(), slaveDataSource); + } + return result; + } + + @Bean + public LocalContainerEntityManagerFactoryBean entityManagerFactory() { + EntityManagerFactoryBuilder entityManagerFactoryBuilder = createEntityManagerFactoryBuilder(jpaProperties); + return entityManagerFactoryBuilder.dataSource(dataSource()).packages(PACKAGE_PATH).build(); + } + + private EntityManagerFactoryBuilder createEntityManagerFactoryBuilder(JpaProperties jpaProperties) { + AbstractJpaVendorAdapter vendorAdapter = new HibernateJpaVendorAdapter(); + return new EntityManagerFactoryBuilder(vendorAdapter, jpaProperties.getProperties(), null); + } + + @Bean + public PlatformTransactionManager transactionManager(EntityManagerFactory entityManagerFactory) { + JpaTransactionManager tm = new JpaTransactionManager(); + tm.setEntityManagerFactory(entityManagerFactory); + return tm; + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/config/datasource/CustomDataSourceProperties.java b/backend/src/main/java/com/woowacourse/zzimkkong/config/datasource/CustomDataSourceProperties.java new file mode 100644 index 000000000..ffb4a9b2d --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/config/datasource/CustomDataSourceProperties.java @@ -0,0 +1,36 @@ +package com.woowacourse.zzimkkong.config.datasource; + +import com.zaxxer.hikari.HikariDataSource; +import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; + +@Configuration +@Profile("prod") +public class CustomDataSourceProperties { + @Bean + @ConfigurationProperties("app.datasource.master") + public DataSourceProperties masterDataSourceProperties() { + return new DataSourceProperties(); + } + + @Bean + @ConfigurationProperties("app.datasource.master.hikari") + public HikariDataSource masterDataSource() { + return masterDataSourceProperties().initializeDataSourceBuilder().type(HikariDataSource.class).build(); + } + + @Bean + @ConfigurationProperties("app.datasource.slave1") + public DataSourceProperties slave1DataSourceProperties() { + return new DataSourceProperties(); + } + + @Bean + @ConfigurationProperties("app.datasource.slave1.hikari") + public HikariDataSource slave1DataSource() { + return slave1DataSourceProperties().initializeDataSourceBuilder().type(HikariDataSource.class).build(); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/config/datasource/ReplicationRoutingDataSource.java b/backend/src/main/java/com/woowacourse/zzimkkong/config/datasource/ReplicationRoutingDataSource.java new file mode 100644 index 000000000..f7a4616a6 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/config/datasource/ReplicationRoutingDataSource.java @@ -0,0 +1,39 @@ +package com.woowacourse.zzimkkong.config.datasource; + +import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.util.Map; +import java.util.stream.Collectors; + +import static com.woowacourse.zzimkkong.config.datasource.CustomDataSourceConfig.MASTER; +import static com.woowacourse.zzimkkong.config.datasource.CustomDataSourceConfig.SLAVE; + +public class ReplicationRoutingDataSource extends AbstractRoutingDataSource { + private CircularList dataSourceNameList; + + @Override + public void setTargetDataSources(Map targetDataSources) { + super.setTargetDataSources(targetDataSources); + + dataSourceNameList = new CircularList<>( + targetDataSources.keySet() + .stream() + .map(Object::toString) + .filter(string -> string.contains(SLAVE)) + .collect(Collectors.toList()) + ); + } + + @Override + protected Object determineCurrentLookupKey() { + boolean isReadOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly(); + if (isReadOnly) { + logger.info("Connection Slave"); + return dataSourceNameList.getOne(); + } else { + logger.info("Connection Master"); + return MASTER; + } + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/controller/AuthController.java b/backend/src/main/java/com/woowacourse/zzimkkong/controller/AuthController.java index f4f9229d0..b276c93e3 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/controller/AuthController.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/controller/AuthController.java @@ -1,18 +1,16 @@ package com.woowacourse.zzimkkong.controller; +import com.woowacourse.zzimkkong.domain.OauthProvider; import com.woowacourse.zzimkkong.dto.member.LoginRequest; import com.woowacourse.zzimkkong.dto.member.TokenResponse; import com.woowacourse.zzimkkong.service.AuthService; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; import javax.validation.Valid; @RestController -@RequestMapping("/api") +@RequestMapping("/api/managers") public class AuthController { private final AuthService authService; @@ -26,8 +24,14 @@ public ResponseEntity login(@RequestBody @Valid final LoginReques .body(authService.login(loginRequest)); } - @PostMapping("/members/token") + @PostMapping("/token") public ResponseEntity token() { return ResponseEntity.ok().build(); } + + @GetMapping("/{oauthProvider}/login/token") + public ResponseEntity loginByOauth(@PathVariable OauthProvider oauthProvider, @RequestParam String code) { + return ResponseEntity.ok() + .body(authService.loginByOauth(oauthProvider, code)); + } } diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/controller/ControllerAdvice.java b/backend/src/main/java/com/woowacourse/zzimkkong/controller/ControllerAdvice.java index 4a47b2bda..3a3af95b2 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/controller/ControllerAdvice.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/controller/ControllerAdvice.java @@ -3,9 +3,11 @@ import com.fasterxml.jackson.databind.exc.InvalidFormatException; import com.woowacourse.zzimkkong.dto.ErrorResponse; import com.woowacourse.zzimkkong.dto.InputFieldErrorResponse; +import com.woowacourse.zzimkkong.dto.OAuthLoginFailErrorResponse; import com.woowacourse.zzimkkong.exception.InputFieldException; import com.woowacourse.zzimkkong.exception.ZzimkkongException; import com.woowacourse.zzimkkong.exception.infrastructure.InfrastructureMalfunctionException; +import com.woowacourse.zzimkkong.exception.member.NoSuchOAuthMemberException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.dao.DataAccessException; @@ -24,6 +26,14 @@ public class ControllerAdvice { private final Logger logger = LoggerFactory.getLogger(ControllerAdvice.class); + @ExceptionHandler(NoSuchOAuthMemberException.class) + public ResponseEntity oAuthLoginFailHandler(final NoSuchOAuthMemberException exception) { + logger.info(exception.getMessage()); + return ResponseEntity + .status(exception.getStatus()) + .body(OAuthLoginFailErrorResponse.from(exception)); + } + @ExceptionHandler(InputFieldException.class) public ResponseEntity inputFieldExceptionHandler(final InputFieldException exception) { logger.info(exception.getMessage()); diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/controller/ManagerSpaceController.java b/backend/src/main/java/com/woowacourse/zzimkkong/controller/ManagerSpaceController.java index 39dcfac44..2c27fb1ba 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/controller/ManagerSpaceController.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/controller/ManagerSpaceController.java @@ -23,8 +23,8 @@ public ManagerSpaceController(final SpaceService spaceService) { public ResponseEntity save( @PathVariable final Long mapId, @RequestBody @Valid final SpaceCreateUpdateRequest spaceCreateRequest, - @Manager final Member manager) { - SpaceCreateResponse spaceCreateResponse = spaceService.saveSpace(mapId, spaceCreateRequest, manager); + @Manager final Member member) { + SpaceCreateResponse spaceCreateResponse = spaceService.saveSpace(mapId, spaceCreateRequest, member); return ResponseEntity .created(URI.create("/api/managers/maps/" + mapId + "/spaces/" + spaceCreateResponse.getId())) .build(); diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/controller/MemberController.java b/backend/src/main/java/com/woowacourse/zzimkkong/controller/MemberController.java index 59a5b22c2..a6e9c67db 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/controller/MemberController.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/controller/MemberController.java @@ -3,6 +3,7 @@ import com.woowacourse.zzimkkong.domain.Manager; import com.woowacourse.zzimkkong.domain.Member; import com.woowacourse.zzimkkong.dto.member.*; +import com.woowacourse.zzimkkong.dto.member.oauth.OauthMemberSaveRequest; import com.woowacourse.zzimkkong.service.MemberService; import com.woowacourse.zzimkkong.service.PresetService; import org.springframework.http.ResponseEntity; @@ -18,7 +19,7 @@ import static com.woowacourse.zzimkkong.dto.ValidatorMessage.EMPTY_MESSAGE; @RestController -@RequestMapping("/api/members") +@RequestMapping("/api/managers") @Validated public class MemberController { private final MemberService memberService; @@ -33,7 +34,15 @@ public MemberController(final MemberService memberService, final PresetService p public ResponseEntity join(@RequestBody @Valid final MemberSaveRequest memberSaveRequest) { MemberSaveResponse memberSaveResponse = memberService.saveMember(memberSaveRequest); return ResponseEntity - .created(URI.create("/api/members/" + memberSaveResponse.getId())) + .created(URI.create("/api/managers/" + memberSaveResponse.getId())) + .build(); + } + + @PostMapping("/oauth") + public ResponseEntity joinByOauth(@RequestBody @Valid final OauthMemberSaveRequest oauthMemberSaveRequest) { + MemberSaveResponse memberSaveResponse = memberService.saveMemberByOauth(oauthMemberSaveRequest); + return ResponseEntity + .created(URI.create("/api/managers/" + memberSaveResponse.getId())) .build(); } @@ -52,7 +61,7 @@ public ResponseEntity createPreset( @Manager final Member manager) { PresetCreateResponse presetCreateResponse = presetService.savePreset(presetCreateRequest, manager); return ResponseEntity - .created(URI.create("/api/members/presets/" + presetCreateResponse.getId())) + .created(URI.create("/api/managers/presets/" + presetCreateResponse.getId())) .build(); } @@ -70,4 +79,24 @@ public ResponseEntity deletePreset( return ResponseEntity.noContent().build(); } + + @GetMapping("/me") + public ResponseEntity findMember(@Manager final Member manager) { + MemberFindResponse memberFindResponse = MemberFindResponse.from(manager); + return ResponseEntity.ok().body(memberFindResponse); + } + + @PutMapping("/me") + public ResponseEntity updateMember( + @Manager final Member manager, + @RequestBody @Valid final MemberUpdateRequest memberUpdateRequest) { + memberService.updateMember(manager, memberUpdateRequest); + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/me") + public ResponseEntity deleteMember(@Manager final Member manager) { + memberService.deleteMember(manager); + return ResponseEntity.noContent().build(); + } } diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/domain/Map.java b/backend/src/main/java/com/woowacourse/zzimkkong/domain/Map.java index c3b9a9bf5..18e187166 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/domain/Map.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/domain/Map.java @@ -40,6 +40,10 @@ public Map(final String name, final String mapDrawing, final String mapImageUrl, this.mapDrawing = mapDrawing; this.mapImageUrl = mapImageUrl; this.member = member; + + if (member != null) { + member.addMap(this); + } } public Map(final Long id, final String name, final String mapDrawing, final String mapImageUrl, final Member member) { diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/domain/Member.java b/backend/src/main/java/com/woowacourse/zzimkkong/domain/Member.java index 26c72f006..cb9865594 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/domain/Member.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/domain/Member.java @@ -20,15 +20,22 @@ public class Member { @Column(nullable = false, length = 50, unique = true) private String email; - @Column(nullable = false, length = 128) + @Column(length = 128) private String password; @Column(nullable = false, length = 20) private String organization; + @Column(length = 10) + @Enumerated(EnumType.STRING) + private OauthProvider oauthProvider; + @OneToMany(mappedBy = "member", cascade = CascadeType.REMOVE, orphanRemoval = true) private List presets = new ArrayList<>(); + @OneToMany(mappedBy = "member", cascade = CascadeType.REMOVE, orphanRemoval = true) + private List maps = new ArrayList<>(); + public Member( final String email, final String password, @@ -47,12 +54,27 @@ public Member( this.id = id; } + public Member(final String email, final String organization, final OauthProvider oauthProvider) { + this.email = email; + this.organization = organization; + this.oauthProvider = oauthProvider; + } + + public Member(final Long id, final String email, final String organization, final OauthProvider oauthProvider) { + this(email, organization, oauthProvider); + this.id = id; + } + public Optional findPresetById(final Long presetId) { return this.presets.stream() .filter(preset -> preset.hasSameId(presetId)) .findAny(); } + public void addMap(final Map map) { + this.maps.add(map); + } + public void addPreset(final Preset preset) { this.presets.add(preset); } @@ -60,4 +82,8 @@ public void addPreset(final Preset preset) { public List getPresets() { return Collections.unmodifiableList(presets); } + + public void update(final String organization) { + this.organization = organization; + } } diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/domain/OauthProvider.java b/backend/src/main/java/com/woowacourse/zzimkkong/domain/OauthProvider.java new file mode 100644 index 000000000..a75614f15 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/domain/OauthProvider.java @@ -0,0 +1,20 @@ +package com.woowacourse.zzimkkong.domain; + +import com.woowacourse.zzimkkong.exception.infrastructure.UnsupportedOauthProviderException; + +import java.util.Arrays; + +public enum OauthProvider { + GITHUB, GOOGLE; + + public static OauthProvider valueOfWithIgnoreCase(String value) { + return Arrays.stream(values()) + .filter(oauthProvider -> oauthProvider.name().equalsIgnoreCase(value)) + .findFirst() + .orElseThrow(UnsupportedOauthProviderException::new); + } + + public boolean isSameAs(OauthProvider oauthProvider) { + return this.equals(oauthProvider); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/domain/Reservation.java b/backend/src/main/java/com/woowacourse/zzimkkong/domain/Reservation.java index 4f0830385..9d3cfc43b 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/domain/Reservation.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/domain/Reservation.java @@ -5,6 +5,7 @@ import lombok.NoArgsConstructor; import javax.persistence.*; +import java.time.LocalDate; import java.time.LocalDateTime; @Getter @@ -16,6 +17,9 @@ public class Reservation { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @Column(nullable = false) + private LocalDate date; + @Column(nullable = false) private LocalDateTime startTime; @@ -37,6 +41,7 @@ public class Reservation { protected Reservation( final Long id, + final LocalDate date, final LocalDateTime startTime, final LocalDateTime endTime, final String password, @@ -44,6 +49,7 @@ protected Reservation( final String description, final Space space) { this.id = id; + this.date = date; this.startTime = startTime.withSecond(0).withNano(0); this.endTime = endTime.withSecond(0).withNano(0); this.password = password; @@ -80,6 +86,7 @@ private boolean equals(final LocalDateTime startDateTime, final LocalDateTime en } public void update(final Reservation updateReservation, final Space space) { + this.date = updateReservation.date; this.startTime = updateReservation.startTime; this.endTime = updateReservation.endTime; this.userName = updateReservation.userName; diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/domain/Setting.java b/backend/src/main/java/com/woowacourse/zzimkkong/domain/Setting.java index b8ae18ec8..63ff3cef2 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/domain/Setting.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/domain/Setting.java @@ -1,5 +1,6 @@ package com.woowacourse.zzimkkong.domain; +import com.woowacourse.zzimkkong.exception.space.*; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @@ -7,6 +8,7 @@ import javax.persistence.Column; import javax.persistence.Embeddable; import java.time.LocalTime; +import java.time.temporal.ChronoUnit; @Getter @Builder @@ -49,5 +51,53 @@ protected Setting( this.reservationMaximumTimeUnit = reservationMaximumTimeUnit; this.reservationEnable = reservationEnable; this.enabledDayOfWeek = enabledDayOfWeek; + + validateSetting(); + } + + private void validateSetting() { + if (availableStartTime.equals(availableEndTime) || availableStartTime.isAfter(availableEndTime)) { + throw new ImpossibleAvailableStartEndTimeException(); + } + + if (isNoneMatchingAvailableTimeAndTimeUnit()) { + throw new TimeUnitMismatchException(); + } + + if (reservationMaximumTimeUnit < reservationMinimumTimeUnit) { + throw new InvalidMinimumMaximumTimeUnitException(); + } + + if (isNotConsistentTimeUnit()) { + throw new TimeUnitInconsistencyException(); + } + + int duration = (int) ChronoUnit.MINUTES.between(availableStartTime, availableEndTime); + if (duration < reservationMaximumTimeUnit) { + throw new NotEnoughAvailableTimeException(); + } + } + + private boolean isNoneMatchingAvailableTimeAndTimeUnit() { + return isNotDivisibleByTimeUnit(availableStartTime.getMinute()) || isNotDivisibleByTimeUnit(availableEndTime.getMinute()); + } + + public boolean isNotDivisibleByTimeUnit(final int minute) { + return minute % this.reservationTimeUnit != 0; + } + + private boolean isNotConsistentTimeUnit() { + return !(isMinimumTimeUnitConsistentWithTimeUnit() && isMaximumTimeUnitConsistentWithTimeUnit()); + } + + private boolean isMinimumTimeUnitConsistentWithTimeUnit() { + int minimumTimeUnitQuotient = reservationMinimumTimeUnit / reservationTimeUnit; + int minimumTimeUnitRemainder = reservationMinimumTimeUnit % reservationTimeUnit; + return minimumTimeUnitRemainder == 0 && 1 <= minimumTimeUnitQuotient; + } + + private boolean isMaximumTimeUnitConsistentWithTimeUnit() { + int maximumTimeUnitRemainder = reservationMaximumTimeUnit % reservationTimeUnit; + return maximumTimeUnitRemainder == 0; } } diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/domain/Space.java b/backend/src/main/java/com/woowacourse/zzimkkong/domain/Space.java index 45bfc79a0..c61989ff8 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/domain/Space.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/domain/Space.java @@ -92,18 +92,14 @@ public boolean isNotBetweenAvailableTime(final LocalDateTime startDateTime, Loca return !(isEqualOrAfterStartTime && isEqualOrBeforeEndTime); } - public boolean isIncorrectTimeUnit(final int minute) { - return minute != 0 && isNotDivideBy(minute); + public boolean isNotDivisibleByTimeUnit(final int minute) { + return setting.isNotDivisibleByTimeUnit(minute); } public boolean isIncorrectMinimumMaximumTimeUnit(final int durationMinutes) { return durationMinutes < getReservationMinimumTimeUnit() || durationMinutes > getReservationMaximumTimeUnit(); } - public boolean isNotDivideBy(final int minute) { - return minute % getReservationTimeUnit() != 0; - } - public boolean isUnableToReserve() { return !getReservationEnable(); } diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/domain/oauth/GithubUserInfo.java b/backend/src/main/java/com/woowacourse/zzimkkong/domain/oauth/GithubUserInfo.java new file mode 100644 index 000000000..f4165f022 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/domain/oauth/GithubUserInfo.java @@ -0,0 +1,30 @@ +package com.woowacourse.zzimkkong.domain.oauth; + +import com.woowacourse.zzimkkong.exception.infrastructure.oauth.NoPublicEmailOnGithubException; + +import java.util.Collections; +import java.util.Map; + +public class GithubUserInfo implements OauthUserInfo { + private final Map info; + + private GithubUserInfo(final Map info) { + this.info = Collections.unmodifiableMap(info); + } + + public static OauthUserInfo from(Map responseBody) { + return new GithubUserInfo(responseBody); + } + + @Override + public String getEmail() { + validatePublicEmailHasBeenSet(); + return (String) info.get("email"); + } + + private void validatePublicEmailHasBeenSet() { + if (info.get("email") == null) { + throw new NoPublicEmailOnGithubException(); + } + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/domain/oauth/GoogleUserInfo.java b/backend/src/main/java/com/woowacourse/zzimkkong/domain/oauth/GoogleUserInfo.java new file mode 100644 index 000000000..6d0898747 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/domain/oauth/GoogleUserInfo.java @@ -0,0 +1,27 @@ +package com.woowacourse.zzimkkong.domain.oauth; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.Collections; +import java.util.Map; + +@Getter +@NoArgsConstructor +public class GoogleUserInfo implements OauthUserInfo { + private Map info; + + private GoogleUserInfo(final Map info) { + this.info = Collections.unmodifiableMap(info); + } + + public static GoogleUserInfo from(final Map responseBody) { + return new GoogleUserInfo(responseBody); + } + + @Override + public String getEmail() { + return (String) info.get("email"); + } +} + diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/domain/oauth/OauthUserInfo.java b/backend/src/main/java/com/woowacourse/zzimkkong/domain/oauth/OauthUserInfo.java new file mode 100644 index 000000000..5d0080ce1 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/domain/oauth/OauthUserInfo.java @@ -0,0 +1,5 @@ +package com.woowacourse.zzimkkong.domain.oauth; + +public interface OauthUserInfo { + String getEmail(); +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/dto/InputFieldErrorResponse.java b/backend/src/main/java/com/woowacourse/zzimkkong/dto/InputFieldErrorResponse.java index 20c185ada..219f6fdd5 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/dto/InputFieldErrorResponse.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/dto/InputFieldErrorResponse.java @@ -2,11 +2,13 @@ import com.woowacourse.zzimkkong.exception.InputFieldException; import lombok.Getter; +import lombok.NoArgsConstructor; import org.springframework.web.bind.MethodArgumentNotValidException; @Getter +@NoArgsConstructor public class InputFieldErrorResponse extends ErrorResponse { - private final String field; + private String field; private InputFieldErrorResponse(final String message, final String field) { super(message); diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/dto/OAuthLoginFailErrorResponse.java b/backend/src/main/java/com/woowacourse/zzimkkong/dto/OAuthLoginFailErrorResponse.java new file mode 100644 index 000000000..7d45c7055 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/dto/OAuthLoginFailErrorResponse.java @@ -0,0 +1,19 @@ +package com.woowacourse.zzimkkong.dto; + +import com.woowacourse.zzimkkong.exception.member.NoSuchOAuthMemberException; +import lombok.Getter; + +@Getter +public class OAuthLoginFailErrorResponse extends ErrorResponse { + private final String email; + + private OAuthLoginFailErrorResponse(String message, String email) { + super(message); + this.email = email; + } + + public static OAuthLoginFailErrorResponse from(NoSuchOAuthMemberException exception) { + String email = exception.getEmail(); + return new OAuthLoginFailErrorResponse(exception.getMessage(), email); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/dto/TimeUnitValidator.java b/backend/src/main/java/com/woowacourse/zzimkkong/dto/TimeUnitValidator.java index 16b34b1ff..9486b38a1 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/dto/TimeUnitValidator.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/dto/TimeUnitValidator.java @@ -5,7 +5,7 @@ import java.util.List; public class TimeUnitValidator implements ConstraintValidator { - private static final List TIME_UNITS = List.of(5, 10, 30, 60, 120); + private static final List TIME_UNITS = List.of(5, 10, 30, 60); @Override public boolean isValid(Integer value, ConstraintValidatorContext context) { diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/dto/member/MemberFindResponse.java b/backend/src/main/java/com/woowacourse/zzimkkong/dto/member/MemberFindResponse.java new file mode 100644 index 000000000..ffdb0890c --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/dto/member/MemberFindResponse.java @@ -0,0 +1,26 @@ +package com.woowacourse.zzimkkong.dto.member; + +import com.woowacourse.zzimkkong.domain.Member; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class MemberFindResponse { + private Long id; + private String email; + private String organization; + + private MemberFindResponse(final Long id, final String email, final String organization) { + this.id = id; + this.email = email; + this.organization = organization; + } + + public static MemberFindResponse from(final Member member) { + return new MemberFindResponse( + member.getId(), + member.getEmail(), + member.getOrganization()); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/dto/member/MemberUpdateRequest.java b/backend/src/main/java/com/woowacourse/zzimkkong/dto/member/MemberUpdateRequest.java new file mode 100644 index 000000000..5cccd90d3 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/dto/member/MemberUpdateRequest.java @@ -0,0 +1,21 @@ +package com.woowacourse.zzimkkong.dto.member; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Pattern; + +import static com.woowacourse.zzimkkong.dto.ValidatorMessage.*; + +@Getter +@NoArgsConstructor +public class MemberUpdateRequest { + @NotNull(message = EMPTY_MESSAGE) + @Pattern(regexp = ORGANIZATION_FORMAT, message = ORGANIZATION_MESSAGE) + private String organization; + + public MemberUpdateRequest(final String organization) { + this.organization = organization; + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/dto/member/oauth/OauthMemberSaveRequest.java b/backend/src/main/java/com/woowacourse/zzimkkong/dto/member/oauth/OauthMemberSaveRequest.java new file mode 100644 index 000000000..4dc67b739 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/dto/member/oauth/OauthMemberSaveRequest.java @@ -0,0 +1,34 @@ +package com.woowacourse.zzimkkong.dto.member.oauth; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +import javax.validation.constraints.Email; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Pattern; + +import static com.woowacourse.zzimkkong.dto.ValidatorMessage.*; + +@Getter +@NoArgsConstructor +public class OauthMemberSaveRequest { + @NotBlank(message = EMPTY_MESSAGE) + @Email(message = EMAIL_MESSAGE) + private String email; + + @NotNull(message = EMPTY_MESSAGE) + @Pattern(regexp = ORGANIZATION_FORMAT, message = ORGANIZATION_MESSAGE) + private String organization; + + @NotNull(message = EMPTY_MESSAGE) + private String oauthProvider; + + public OauthMemberSaveRequest(final String email, + final String organization, + final String oauthProvider) { + this.email = email; + this.organization = organization; + this.oauthProvider = oauthProvider; + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/dto/space/SettingsRequest.java b/backend/src/main/java/com/woowacourse/zzimkkong/dto/space/SettingsRequest.java index 249ed7d99..1688103b2 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/dto/space/SettingsRequest.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/dto/space/SettingsRequest.java @@ -26,7 +26,7 @@ public class SettingsRequest { private Integer reservationMinimumTimeUnit = 10; - private Integer reservationMaximumTimeUnit = 1440; + private Integer reservationMaximumTimeUnit = 120; private Boolean reservationEnable = true; diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/exception/InputFieldException.java b/backend/src/main/java/com/woowacourse/zzimkkong/exception/InputFieldException.java index 1c6c2c691..8df96b44b 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/exception/InputFieldException.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/exception/InputFieldException.java @@ -10,6 +10,8 @@ public class InputFieldException extends ZzimkkongException { protected static final String RESERVATION_PASSWORD = "password"; protected static final String START_DATE_TIME = "startDateTime"; protected static final String END_DATE_TIME = "endDateTime"; + protected static final String AVAILABLE_START_END_TIME = "availableStartEndTime"; + protected static final String MINIMUM_MAXIMUM_TIME_UNIT = "minimumMaximumTimeUnit"; private final String field; @@ -17,8 +19,4 @@ public InputFieldException(final String message, final HttpStatus status, final super(message, status); this.field = field; } - - public String getField() { - return field; - } } diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/exception/authorization/OauthProviderMismatchException.java b/backend/src/main/java/com/woowacourse/zzimkkong/exception/authorization/OauthProviderMismatchException.java new file mode 100644 index 000000000..065fc0bd4 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/exception/authorization/OauthProviderMismatchException.java @@ -0,0 +1,25 @@ +package com.woowacourse.zzimkkong.exception.authorization; + +import com.woowacourse.zzimkkong.domain.OauthProvider; +import com.woowacourse.zzimkkong.exception.ZzimkkongException; +import org.springframework.http.HttpStatus; + +public class OauthProviderMismatchException extends ZzimkkongException { + private static final String MESSAGE_FORMAT = "소셜 로그인 제공자가 다릅니다. %s를 통해 로그인하세요."; + + public OauthProviderMismatchException(final String message) { + super(message, HttpStatus.UNAUTHORIZED); + } + + public static OauthProviderMismatchException from(OauthProvider oauthProvider) { + String message = formatMessage(oauthProvider); + return new OauthProviderMismatchException(message); + } + + private static String formatMessage(OauthProvider oauthProvider) { + if (oauthProvider != null) { + return String.format(MESSAGE_FORMAT, oauthProvider.name()); + } + return String.format(MESSAGE_FORMAT, "이메일/비밀번호"); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/NoMasterDataSourceException.java b/backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/NoMasterDataSourceException.java new file mode 100644 index 000000000..81f47ac2d --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/NoMasterDataSourceException.java @@ -0,0 +1,12 @@ +package com.woowacourse.zzimkkong.exception.infrastructure; + +import com.woowacourse.zzimkkong.exception.ZzimkkongException; +import org.springframework.http.HttpStatus; + +public class NoMasterDataSourceException extends ZzimkkongException { + private static final String MESSAGE = "Master DB의 DataSource 설정이 올바르지 않습니다."; + + public NoMasterDataSourceException() { + super(MESSAGE, HttpStatus.INTERNAL_SERVER_ERROR); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/UnsupportedOauthProviderException.java b/backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/UnsupportedOauthProviderException.java new file mode 100644 index 000000000..382f87cd8 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/UnsupportedOauthProviderException.java @@ -0,0 +1,12 @@ +package com.woowacourse.zzimkkong.exception.infrastructure; + +import com.woowacourse.zzimkkong.exception.ZzimkkongException; +import org.springframework.http.HttpStatus; + +public class UnsupportedOauthProviderException extends ZzimkkongException { + private static final String MESSAGE = "지원되지 않는 Oauth 제공자입니다."; + + public UnsupportedOauthProviderException() { + super(MESSAGE, HttpStatus.BAD_REQUEST); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/oauth/ErrorResponseToGetAccessTokenException.java b/backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/oauth/ErrorResponseToGetAccessTokenException.java new file mode 100644 index 000000000..0637f2d0b --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/oauth/ErrorResponseToGetAccessTokenException.java @@ -0,0 +1,12 @@ +package com.woowacourse.zzimkkong.exception.infrastructure.oauth; + +import com.woowacourse.zzimkkong.exception.infrastructure.InfrastructureMalfunctionException; +import org.springframework.http.HttpStatus; + +public class ErrorResponseToGetAccessTokenException extends InfrastructureMalfunctionException { + private static final String MESSAGE = "소셜 로그인에 실패했습니다. 다시 시도해주세요."; + + public ErrorResponseToGetAccessTokenException(String errorMessageFromGithub) { + super(MESSAGE, new Throwable(errorMessageFromGithub), HttpStatus.INTERNAL_SERVER_ERROR); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/oauth/NoPublicEmailOnGithubException.java b/backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/oauth/NoPublicEmailOnGithubException.java new file mode 100644 index 000000000..007123bf5 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/oauth/NoPublicEmailOnGithubException.java @@ -0,0 +1,12 @@ +package com.woowacourse.zzimkkong.exception.infrastructure.oauth; + +import com.woowacourse.zzimkkong.exception.infrastructure.InfrastructureMalfunctionException; +import org.springframework.http.HttpStatus; + +public class NoPublicEmailOnGithubException extends InfrastructureMalfunctionException { + private static final String MESSAGE = "소셜 로그인에 실패했습니다. 깃허브의 Public Email(Setting -> Profile)을 설정해주시기 바랍니다."; + + public NoPublicEmailOnGithubException() { + super(MESSAGE, HttpStatus.BAD_REQUEST); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/oauth/UnableToGetTokenResponseFromGithubException.java b/backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/oauth/UnableToGetTokenResponseFromGithubException.java new file mode 100644 index 000000000..58320c894 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/oauth/UnableToGetTokenResponseFromGithubException.java @@ -0,0 +1,12 @@ +package com.woowacourse.zzimkkong.exception.infrastructure.oauth; + +import com.woowacourse.zzimkkong.exception.infrastructure.InfrastructureMalfunctionException; +import org.springframework.http.HttpStatus; + +public class UnableToGetTokenResponseFromGithubException extends InfrastructureMalfunctionException { + private static final String MESSAGE = "소셜 로그인에 실패했습니다."; + + public UnableToGetTokenResponseFromGithubException() { + super(MESSAGE, HttpStatus.INTERNAL_SERVER_ERROR); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/oauth/UnableToGetTokenResponseFromGoogleException.java b/backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/oauth/UnableToGetTokenResponseFromGoogleException.java new file mode 100644 index 000000000..5f79fbe12 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/oauth/UnableToGetTokenResponseFromGoogleException.java @@ -0,0 +1,12 @@ +package com.woowacourse.zzimkkong.exception.infrastructure.oauth; + +import com.woowacourse.zzimkkong.exception.infrastructure.InfrastructureMalfunctionException; +import org.springframework.http.HttpStatus; + +public class UnableToGetTokenResponseFromGoogleException extends InfrastructureMalfunctionException { + private static final String MESSAGE = "구글 소셜 로그인에 실패했습니다."; + + public UnableToGetTokenResponseFromGoogleException() { + super(MESSAGE, HttpStatus.INTERNAL_SERVER_ERROR); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/exception/map/NoSuchMapException.java b/backend/src/main/java/com/woowacourse/zzimkkong/exception/map/NoSuchMapException.java index b5b0fe1c7..a288ea443 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/exception/map/NoSuchMapException.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/exception/map/NoSuchMapException.java @@ -7,6 +7,6 @@ public class NoSuchMapException extends ZzimkkongException { private static final String MESSAGE = "존재하지 않는 맵입니다."; public NoSuchMapException() { - super(MESSAGE, HttpStatus.BAD_REQUEST); + super(MESSAGE, HttpStatus.NOT_FOUND); } } diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/exception/member/NoSuchMemberException.java b/backend/src/main/java/com/woowacourse/zzimkkong/exception/member/NoSuchMemberException.java index d9bd3f153..27a2ca087 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/exception/member/NoSuchMemberException.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/exception/member/NoSuchMemberException.java @@ -7,6 +7,6 @@ public class NoSuchMemberException extends InputFieldException { private static final String MESSAGE = "존재하지 않는 회원입니다."; public NoSuchMemberException() { - super(MESSAGE, HttpStatus.BAD_REQUEST, EMAIL); + super(MESSAGE, HttpStatus.NOT_FOUND, EMAIL); } } diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/exception/member/NoSuchOAuthMemberException.java b/backend/src/main/java/com/woowacourse/zzimkkong/exception/member/NoSuchOAuthMemberException.java new file mode 100644 index 000000000..70e69ffc7 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/exception/member/NoSuchOAuthMemberException.java @@ -0,0 +1,17 @@ +package com.woowacourse.zzimkkong.exception.member; + +import com.woowacourse.zzimkkong.exception.ZzimkkongException; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public class NoSuchOAuthMemberException extends ZzimkkongException { + private static final String MESSAGE = "소셜 로그인 회원이 아닙니다. 회원가입을 진행합니다."; + + private final String email; + + public NoSuchOAuthMemberException(String email) { + super(MESSAGE, HttpStatus.NOT_FOUND); + this.email = email; + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/exception/member/ReservationExistsOnMemberException.java b/backend/src/main/java/com/woowacourse/zzimkkong/exception/member/ReservationExistsOnMemberException.java new file mode 100644 index 000000000..81c27e590 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/exception/member/ReservationExistsOnMemberException.java @@ -0,0 +1,12 @@ +package com.woowacourse.zzimkkong.exception.member; + +import com.woowacourse.zzimkkong.exception.ZzimkkongException; +import org.springframework.http.HttpStatus; + +public class ReservationExistsOnMemberException extends ZzimkkongException { + private static final String MESSAGE = "예약이 존재하는 공간이 있습니다. 사전에 미리 취소해주세요."; + + public ReservationExistsOnMemberException() { + super(MESSAGE, HttpStatus.BAD_REQUEST); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/exception/preset/NoSuchPresetException.java b/backend/src/main/java/com/woowacourse/zzimkkong/exception/preset/NoSuchPresetException.java index a791b1563..c063738d6 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/exception/preset/NoSuchPresetException.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/exception/preset/NoSuchPresetException.java @@ -7,6 +7,6 @@ public class NoSuchPresetException extends ZzimkkongException { private static final String MESSAGE = "존재하지 않는 프리셋입니다."; public NoSuchPresetException() { - super(MESSAGE, HttpStatus.BAD_REQUEST); + super(MESSAGE, HttpStatus.NOT_FOUND); } } diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/exception/reservation/NoSuchReservationException.java b/backend/src/main/java/com/woowacourse/zzimkkong/exception/reservation/NoSuchReservationException.java index 8ace9ae7d..60bca6a3b 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/exception/reservation/NoSuchReservationException.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/exception/reservation/NoSuchReservationException.java @@ -7,6 +7,6 @@ public class NoSuchReservationException extends ZzimkkongException { private static final String MESSAGE = "존재하지 않는 예약입니다."; public NoSuchReservationException() { - super(MESSAGE, HttpStatus.BAD_REQUEST); + super(MESSAGE, HttpStatus.NOT_FOUND); } } diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/exception/space/ImpossibleAvailableStartEndTimeException.java b/backend/src/main/java/com/woowacourse/zzimkkong/exception/space/ImpossibleAvailableStartEndTimeException.java new file mode 100644 index 000000000..4158b56f2 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/exception/space/ImpossibleAvailableStartEndTimeException.java @@ -0,0 +1,13 @@ +package com.woowacourse.zzimkkong.exception.space; + +import com.woowacourse.zzimkkong.exception.InputFieldException; +import com.woowacourse.zzimkkong.exception.ZzimkkongException; +import org.springframework.http.HttpStatus; + +public class ImpossibleAvailableStartEndTimeException extends InputFieldException { + private static final String MESSAGE = "예약이 닫힐 시간은 예약이 열릴 시간보다 이전일 수 없습니다."; + + public ImpossibleAvailableStartEndTimeException() { + super(MESSAGE, HttpStatus.BAD_REQUEST, AVAILABLE_START_END_TIME); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/exception/space/InvalidMinimumMaximumTimeUnitException.java b/backend/src/main/java/com/woowacourse/zzimkkong/exception/space/InvalidMinimumMaximumTimeUnitException.java new file mode 100644 index 000000000..b4eb43c43 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/exception/space/InvalidMinimumMaximumTimeUnitException.java @@ -0,0 +1,13 @@ +package com.woowacourse.zzimkkong.exception.space; + +import com.woowacourse.zzimkkong.exception.InputFieldException; +import com.woowacourse.zzimkkong.exception.ZzimkkongException; +import org.springframework.http.HttpStatus; + +public class InvalidMinimumMaximumTimeUnitException extends InputFieldException { + private static final String MESSAGE = "최대 예약 가능시간은 최소 예약 가능시간보다 작을 수 없습니다"; + + public InvalidMinimumMaximumTimeUnitException() { + super(MESSAGE, HttpStatus.BAD_REQUEST, MINIMUM_MAXIMUM_TIME_UNIT); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/exception/space/NoSuchDayOfWeekException.java b/backend/src/main/java/com/woowacourse/zzimkkong/exception/space/NoSuchDayOfWeekException.java index d6df8dddf..f59a6e3be 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/exception/space/NoSuchDayOfWeekException.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/exception/space/NoSuchDayOfWeekException.java @@ -7,6 +7,6 @@ public class NoSuchDayOfWeekException extends ZzimkkongException { private static final String MESSAGE = "존재하지 않는 요일입니다."; public NoSuchDayOfWeekException() { - super(MESSAGE, HttpStatus.BAD_REQUEST); + super(MESSAGE, HttpStatus.NOT_FOUND); } } diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/exception/space/NoSuchSpaceException.java b/backend/src/main/java/com/woowacourse/zzimkkong/exception/space/NoSuchSpaceException.java index f5648169e..457c306f4 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/exception/space/NoSuchSpaceException.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/exception/space/NoSuchSpaceException.java @@ -7,6 +7,6 @@ public class NoSuchSpaceException extends ZzimkkongException { private static final String MESSAGE = "존재하지 않는 공간입니다."; public NoSuchSpaceException() { - super(MESSAGE, HttpStatus.BAD_REQUEST); + super(MESSAGE, HttpStatus.NOT_FOUND); } } diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/exception/space/NotEnoughAvailableTimeException.java b/backend/src/main/java/com/woowacourse/zzimkkong/exception/space/NotEnoughAvailableTimeException.java new file mode 100644 index 000000000..5670b1d5c --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/exception/space/NotEnoughAvailableTimeException.java @@ -0,0 +1,13 @@ +package com.woowacourse.zzimkkong.exception.space; + +import com.woowacourse.zzimkkong.exception.InputFieldException; +import com.woowacourse.zzimkkong.exception.ZzimkkongException; +import org.springframework.http.HttpStatus; + +public class NotEnoughAvailableTimeException extends InputFieldException { + private static final String MESSAGE = "예약 가능한 시간의 범위가 최대 예약 가능 시간보다 작을 수 없습니다."; + + public NotEnoughAvailableTimeException() { + super(MESSAGE, HttpStatus.BAD_REQUEST, AVAILABLE_START_END_TIME); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/exception/space/TimeUnitInconsistencyException.java b/backend/src/main/java/com/woowacourse/zzimkkong/exception/space/TimeUnitInconsistencyException.java new file mode 100644 index 000000000..cecfabc81 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/exception/space/TimeUnitInconsistencyException.java @@ -0,0 +1,13 @@ +package com.woowacourse.zzimkkong.exception.space; + +import com.woowacourse.zzimkkong.exception.InputFieldException; +import com.woowacourse.zzimkkong.exception.ZzimkkongException; +import org.springframework.http.HttpStatus; + +public class TimeUnitInconsistencyException extends InputFieldException { + private static final String MESSAGE = "최소, 최대 예약 가능 시간의 단위는 예약 시간 단위와 일치해야합니다"; + + public TimeUnitInconsistencyException() { + super(MESSAGE, HttpStatus.BAD_REQUEST, MINIMUM_MAXIMUM_TIME_UNIT); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/exception/space/TimeUnitMismatchException.java b/backend/src/main/java/com/woowacourse/zzimkkong/exception/space/TimeUnitMismatchException.java new file mode 100644 index 000000000..d80111de2 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/exception/space/TimeUnitMismatchException.java @@ -0,0 +1,13 @@ +package com.woowacourse.zzimkkong.exception.space; + +import com.woowacourse.zzimkkong.exception.InputFieldException; +import com.woowacourse.zzimkkong.exception.ZzimkkongException; +import org.springframework.http.HttpStatus; + +public class TimeUnitMismatchException extends InputFieldException { + private static final String MESSAGE = "예약이 열릴 시간과 닫힐 시간은 예약 시간 단위와 맞아야 합니다"; + + public TimeUnitMismatchException() { + super(MESSAGE, HttpStatus.BAD_REQUEST, AVAILABLE_START_END_TIME); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/JwtUtils.java b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/JwtUtils.java index 35f3e3a2a..c229f20c3 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/JwtUtils.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/JwtUtils.java @@ -46,7 +46,7 @@ public void validateToken(String token) { } public String getPayload(String token) { - return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject(); + return jwtParser.parseClaimsJws(token).getBody().getSubject(); } public static PayloadBuilder payloadBuilder() { diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/oauth/GithubRequester.java b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/oauth/GithubRequester.java new file mode 100644 index 000000000..7083eaee9 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/oauth/GithubRequester.java @@ -0,0 +1,96 @@ +package com.woowacourse.zzimkkong.infrastructure.oauth; + +import com.woowacourse.zzimkkong.domain.OauthProvider; +import com.woowacourse.zzimkkong.domain.oauth.GithubUserInfo; +import com.woowacourse.zzimkkong.domain.oauth.OauthUserInfo; +import com.woowacourse.zzimkkong.exception.infrastructure.oauth.ErrorResponseToGetAccessTokenException; +import com.woowacourse.zzimkkong.exception.infrastructure.oauth.UnableToGetTokenResponseFromGithubException; +import org.springframework.context.annotation.PropertySource; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.Map; + +@PropertySource("classpath:config/oauth.properties") +public class GithubRequester implements OauthAPIRequester { + private final String clientId; + private final String secretId; + private final WebClient githubOauthLoginClient; + private final WebClient githubOpenApiClient; + + public GithubRequester( + final String clientId, + final String secretId, + final String githubOauthUrl, + final String githubOpenApiUrl) { + this.clientId = clientId; + this.secretId = secretId; + this.githubOauthLoginClient = githubOauthLoginClient(githubOauthUrl); + this.githubOpenApiClient = githubOpenApiClient(githubOpenApiUrl); + } + + @Override + public boolean supports(final OauthProvider oauthProvider) { + return oauthProvider.isSameAs(OauthProvider.GITHUB); + } + + @Override + public OauthUserInfo getUserInfoByCode(final String code) { + String token = getToken(code); + return getUserInfo(token); + } + + private String getToken(final String code) { + Map responseBody = githubOauthLoginClient + .post() + .uri(uriBuilder -> uriBuilder + .path("/access_token") + .queryParam("code", code) + .queryParam("client_id", clientId) + .queryParam("client_secret", secretId) + .build()) + .retrieve() + .bodyToMono(new ParameterizedTypeReference>() { + }) + .blockOptional() + .orElseThrow(UnableToGetTokenResponseFromGithubException::new); + validateResponseBody(responseBody); + return responseBody.get("access_token").toString(); + } + + private void validateResponseBody(Map responseBody) { + if (!responseBody.containsKey("access_token")) { + throw new ErrorResponseToGetAccessTokenException(responseBody.get("error_description").toString()); + } + } + + private OauthUserInfo getUserInfo(final String token) { + Map responseBody = githubOpenApiClient + .get() + .uri("/user") + .header(HttpHeaders.AUTHORIZATION, "token " + token) + .retrieve() + .bodyToMono(new ParameterizedTypeReference>() { + }) + .blockOptional() + .orElseThrow(UnableToGetTokenResponseFromGithubException::new); + + return GithubUserInfo.from(responseBody); + } + + private WebClient githubOauthLoginClient(String githubOauthUrl) { + return WebClient.builder() + .baseUrl(githubOauthUrl) + .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) + .build(); + } + + private WebClient githubOpenApiClient(String githubOpenApiUrl) { + return WebClient.builder() + .baseUrl(githubOpenApiUrl) + .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) + .build(); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/oauth/GoogleRequester.java b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/oauth/GoogleRequester.java new file mode 100644 index 000000000..41b66bb85 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/oauth/GoogleRequester.java @@ -0,0 +1,108 @@ +package com.woowacourse.zzimkkong.infrastructure.oauth; + +import com.woowacourse.zzimkkong.domain.OauthProvider; +import com.woowacourse.zzimkkong.domain.oauth.GoogleUserInfo; +import com.woowacourse.zzimkkong.domain.oauth.OauthUserInfo; +import com.woowacourse.zzimkkong.exception.infrastructure.oauth.ErrorResponseToGetAccessTokenException; +import com.woowacourse.zzimkkong.exception.infrastructure.oauth.UnableToGetTokenResponseFromGoogleException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.PropertySource; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; + +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.Map; + +@Component +@PropertySource("classpath:config/oauth.properties") +public class GoogleRequester implements OauthAPIRequester { + private final String clientId; + private final String secretId; + private final String redirectUri; + private final String baseLoginUri; + private final String baseUserUri; + + public GoogleRequester( + @Value("${google.client-id}") final String clientId, + @Value("${google.secret-id}") final String secretId, + @Value("${google.uri.redirect}") final String redirectUri, + @Value("${google.uri.oauth-login}") final String baseLoginUri, + @Value("${google.uri.user-info}") final String baseUserUri) { + this.clientId = clientId; + this.secretId = secretId; + this.redirectUri = redirectUri; + this.baseLoginUri = baseLoginUri; + this.baseUserUri = baseUserUri; + } + + @Override + public boolean supports(final OauthProvider oauthProvider) { + return oauthProvider.isSameAs(OauthProvider.GOOGLE); + } + + @Override + public OauthUserInfo getUserInfoByCode(final String code) { + String token = getToken(code); + return getUserInfo(token); + } + + private String getToken(final String code) { + Map responseBody = googleOauthLoginClient() + .post() + .uri(uriBuilder -> uriBuilder + .queryParam("code", code) + .queryParam("client_id", clientId) + .queryParam("client_secret", secretId) + .queryParam("redirect_uri", redirectUri) + .queryParam("grant_type", "authorization_code") + .build()) + .headers(header -> { + header.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); + header.setAcceptCharset(Collections.singletonList(StandardCharsets.UTF_8)); + }) + .retrieve() + .bodyToMono(new ParameterizedTypeReference>() { + }) + .blockOptional() + .orElseThrow(UnableToGetTokenResponseFromGoogleException::new); + validateResponseBody(responseBody); + return responseBody.get("access_token").toString(); + } + + private void validateResponseBody(Map responseBody) { + if (!responseBody.containsKey("access_token")) { + throw new ErrorResponseToGetAccessTokenException(responseBody.get("error_description").toString()); + } + } + + private GoogleUserInfo getUserInfo(final String token) { + Map responseBody = googleUserClient() + .get() + .headers(httpHeaders -> httpHeaders.setBearerAuth(token)) + .retrieve() + .bodyToMono(new ParameterizedTypeReference>() { + }) + .blockOptional() + .orElseThrow(UnableToGetTokenResponseFromGoogleException::new); + + return GoogleUserInfo.from(responseBody); + } + + private WebClient googleOauthLoginClient() { + return WebClient.builder() + .baseUrl(baseLoginUri) + .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) + .build(); + } + + private WebClient googleUserClient() { + return WebClient.builder() + .baseUrl(baseUserUri) + .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) + .build(); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/oauth/OauthAPIRequester.java b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/oauth/OauthAPIRequester.java new file mode 100644 index 000000000..05154d89a --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/oauth/OauthAPIRequester.java @@ -0,0 +1,10 @@ +package com.woowacourse.zzimkkong.infrastructure.oauth; + +import com.woowacourse.zzimkkong.domain.OauthProvider; +import com.woowacourse.zzimkkong.domain.oauth.OauthUserInfo; + +public interface OauthAPIRequester { + boolean supports(OauthProvider oauthProvider); + + OauthUserInfo getUserInfoByCode(String code); +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/oauth/OauthHandler.java b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/oauth/OauthHandler.java new file mode 100644 index 000000000..45fffe64d --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/oauth/OauthHandler.java @@ -0,0 +1,29 @@ +package com.woowacourse.zzimkkong.infrastructure.oauth; + +import com.woowacourse.zzimkkong.domain.OauthProvider; +import com.woowacourse.zzimkkong.domain.oauth.OauthUserInfo; +import com.woowacourse.zzimkkong.exception.infrastructure.UnsupportedOauthProviderException; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +public class OauthHandler { + private final List oauthAPIRequesters; + + public OauthHandler(final List oauthAPIRequesters) { + this.oauthAPIRequesters = oauthAPIRequesters; + } + + public OauthUserInfo getUserInfoFromCode(final OauthProvider oauthProvider, final String code) { + OauthAPIRequester requester = getRequester(oauthProvider); + return requester.getUserInfoByCode(code); + } + + private OauthAPIRequester getRequester(final OauthProvider oauthProvider) { + return oauthAPIRequesters.stream() + .filter(requester -> requester.supports(oauthProvider)) + .findFirst() + .orElseThrow(UnsupportedOauthProviderException::new); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/oauth/StringToOauthProviderConverter.java b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/oauth/StringToOauthProviderConverter.java new file mode 100644 index 000000000..9c67f24a1 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/oauth/StringToOauthProviderConverter.java @@ -0,0 +1,13 @@ +package com.woowacourse.zzimkkong.infrastructure.oauth; + +import com.woowacourse.zzimkkong.domain.OauthProvider; +import org.springframework.core.convert.converter.Converter; + +import java.util.Locale; + +public class StringToOauthProviderConverter implements Converter { + @Override + public OauthProvider convert(String source) { + return OauthProvider.valueOf(source.toUpperCase(Locale.ROOT)); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/repository/ReservationRepository.java b/backend/src/main/java/com/woowacourse/zzimkkong/repository/ReservationRepository.java index 46bf08e1b..edcf7331a 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/repository/ReservationRepository.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/repository/ReservationRepository.java @@ -3,17 +3,13 @@ import com.woowacourse.zzimkkong.domain.Reservation; import org.springframework.data.jpa.repository.JpaRepository; +import java.time.LocalDate; import java.time.LocalDateTime; import java.util.Collection; import java.util.List; -public interface ReservationRepository extends JpaRepository { - List findAllBySpaceIdInAndStartTimeIsBetweenAndEndTimeIsBetween( - final Collection spaceIds, - final LocalDateTime firstStartTime, - final LocalDateTime firstEndTime, - final LocalDateTime secondStartTime, - final LocalDateTime secondEndTime); +public interface ReservationRepository extends JpaRepository, ReservationRepositoryCustom { + List findAllBySpaceIdInAndDate(final Collection spaceIds, final LocalDate date); Boolean existsBySpaceIdAndEndTimeAfter(Long spaceId, LocalDateTime now); } diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/repository/ReservationRepositoryCustom.java b/backend/src/main/java/com/woowacourse/zzimkkong/repository/ReservationRepositoryCustom.java new file mode 100644 index 000000000..41f8f7c49 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/repository/ReservationRepositoryCustom.java @@ -0,0 +1,7 @@ +package com.woowacourse.zzimkkong.repository; + +import com.woowacourse.zzimkkong.domain.Member; + +public interface ReservationRepositoryCustom { + boolean existsReservationsByMemberFromToday(Member member); +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/repository/ReservationRepositoryImpl.java b/backend/src/main/java/com/woowacourse/zzimkkong/repository/ReservationRepositoryImpl.java new file mode 100644 index 000000000..c28c67ffc --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/repository/ReservationRepositoryImpl.java @@ -0,0 +1,25 @@ +package com.woowacourse.zzimkkong.repository; + +import com.woowacourse.zzimkkong.domain.Member; + +import javax.persistence.EntityManager; +import java.time.LocalDateTime; + +public class ReservationRepositoryImpl implements ReservationRepositoryCustom { + private final EntityManager entityManager; + + public ReservationRepositoryImpl(final EntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + public boolean existsReservationsByMemberFromToday(Member member) { + return entityManager.createQuery( + "SELECT COUNT(r) > 0 FROM Reservation r " + + "JOIN r.space s JOIN s.map m " + + "WHERE m.member = :member AND r.endTime >= :currentTime", Boolean.class) + .setParameter("member", member) + .setParameter("currentTime", LocalDateTime.now()) + .getSingleResult(); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/service/AuthService.java b/backend/src/main/java/com/woowacourse/zzimkkong/service/AuthService.java index 8e3ac263a..6ddb6bfae 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/service/AuthService.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/service/AuthService.java @@ -1,11 +1,16 @@ package com.woowacourse.zzimkkong.service; import com.woowacourse.zzimkkong.domain.Member; +import com.woowacourse.zzimkkong.domain.OauthProvider; +import com.woowacourse.zzimkkong.domain.oauth.OauthUserInfo; import com.woowacourse.zzimkkong.dto.member.LoginRequest; import com.woowacourse.zzimkkong.dto.member.TokenResponse; +import com.woowacourse.zzimkkong.exception.authorization.OauthProviderMismatchException; import com.woowacourse.zzimkkong.exception.member.NoSuchMemberException; +import com.woowacourse.zzimkkong.exception.member.NoSuchOAuthMemberException; import com.woowacourse.zzimkkong.exception.member.PasswordMismatchException; import com.woowacourse.zzimkkong.infrastructure.JwtUtils; +import com.woowacourse.zzimkkong.infrastructure.oauth.OauthHandler; import com.woowacourse.zzimkkong.repository.MemberRepository; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @@ -19,17 +24,20 @@ public class AuthService { private final MemberRepository members; private final JwtUtils jwtUtils; private final PasswordEncoder passwordEncoder; + private final OauthHandler oauthHandler; public AuthService(final MemberRepository members, final JwtUtils jwtUtils, - final PasswordEncoder passwordEncoder) { + final PasswordEncoder passwordEncoder, + final OauthHandler oauthHandler) { this.members = members; this.jwtUtils = jwtUtils; this.passwordEncoder = passwordEncoder; + this.oauthHandler = oauthHandler; } @Transactional(readOnly = true) - public TokenResponse login(LoginRequest loginRequest) { + public TokenResponse login(final LoginRequest loginRequest) { Member findMember = members.findByEmail(loginRequest.getEmail()) .orElseThrow(NoSuchMemberException::new); @@ -40,7 +48,21 @@ public TokenResponse login(LoginRequest loginRequest) { return TokenResponse.from(token); } - private String issueToken(Member findMember) { + @Transactional(readOnly = true) + public TokenResponse loginByOauth(final OauthProvider oauthProvider, final String code) { + OauthUserInfo userInfoFromCode = oauthHandler.getUserInfoFromCode(oauthProvider, code); + String email = userInfoFromCode.getEmail(); + + Member member = members.findByEmail(email) + .orElseThrow(() -> new NoSuchOAuthMemberException(email)); + + validateOauthProvider(oauthProvider, member); + + String token = issueToken(member); + return TokenResponse.from(token); + } + + private String issueToken(final Member findMember) { Map payload = JwtUtils.payloadBuilder() .setSubject(findMember.getEmail()) .build(); @@ -48,9 +70,16 @@ private String issueToken(Member findMember) { return jwtUtils.createToken(payload); } - private void validatePassword(Member findMember, String password) { + private void validatePassword(final Member findMember, final String password) { if (!passwordEncoder.matches(password, findMember.getPassword())) { throw new PasswordMismatchException(); } } + + private void validateOauthProvider(final OauthProvider oauthProvider, final Member member) { + OauthProvider memberOauthProvider = member.getOauthProvider(); + if (!oauthProvider.equals(memberOauthProvider)) { + throw OauthProviderMismatchException.from(memberOauthProvider); + } + } } diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/service/MapService.java b/backend/src/main/java/com/woowacourse/zzimkkong/service/MapService.java index fb7b650f4..4fe4b0f6b 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/service/MapService.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/service/MapService.java @@ -71,6 +71,14 @@ public MapFindAllResponse findAllMaps(final Member manager) { .collect(collectingAndThen(toList(), mapFindResponses -> MapFindAllResponse.of(mapFindResponses, manager))); } + @Transactional(readOnly = true) + public MapFindResponse findMapBySharingId(final String sharingMapId) { + Long mapId = sharingIdGenerator.parseIdFrom(sharingMapId); + Map map = maps.findById(mapId) + .orElseThrow(NoSuchMapException::new); + return MapFindResponse.of(map, sharingIdGenerator.from(map)); + } + public void updateMap(final Long mapId, final MapCreateUpdateRequest mapCreateUpdateRequest, final Member manager) { Map map = maps.findById(mapId) @@ -112,11 +120,4 @@ public static void validateManagerOfMap(final Map map, final Member manager) { throw new NoAuthorityOnMapException(); } } - - public MapFindResponse findMapBySharingId(final String sharingMapId) { - Long mapId = sharingIdGenerator.parseIdFrom(sharingMapId); - Map map = maps.findById(mapId) - .orElseThrow(NoSuchMapException::new); - return MapFindResponse.of(map, sharingIdGenerator.from(map)); - } } diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/service/MemberService.java b/backend/src/main/java/com/woowacourse/zzimkkong/service/MemberService.java index 4226aa1ab..bee87ce2e 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/service/MemberService.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/service/MemberService.java @@ -1,10 +1,16 @@ package com.woowacourse.zzimkkong.service; import com.woowacourse.zzimkkong.domain.Member; +import com.woowacourse.zzimkkong.domain.OauthProvider; import com.woowacourse.zzimkkong.dto.member.MemberSaveRequest; import com.woowacourse.zzimkkong.dto.member.MemberSaveResponse; +import com.woowacourse.zzimkkong.dto.member.MemberUpdateRequest; +import com.woowacourse.zzimkkong.dto.member.oauth.OauthMemberSaveRequest; import com.woowacourse.zzimkkong.exception.member.DuplicateEmailException; +import com.woowacourse.zzimkkong.exception.member.ReservationExistsOnMemberException; +import com.woowacourse.zzimkkong.infrastructure.oauth.OauthHandler; import com.woowacourse.zzimkkong.repository.MemberRepository; +import com.woowacourse.zzimkkong.repository.ReservationRepository; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -13,12 +19,18 @@ @Transactional public class MemberService { private final MemberRepository members; + private final ReservationRepository reservations; private final PasswordEncoder passwordEncoder; + private final OauthHandler oauthHandler; public MemberService(final MemberRepository members, - final PasswordEncoder passwordEncoder) { + final ReservationRepository reservations, + final PasswordEncoder passwordEncoder, + final OauthHandler oauthHandler) { this.members = members; + this.reservations = reservations; this.passwordEncoder = passwordEncoder; + this.oauthHandler = oauthHandler; } public MemberSaveResponse saveMember(final MemberSaveRequest memberSaveRequest) { @@ -34,10 +46,38 @@ public MemberSaveResponse saveMember(final MemberSaveRequest memberSaveRequest) return MemberSaveResponse.from(saveMember); } + public MemberSaveResponse saveMemberByOauth(final OauthMemberSaveRequest oauthMemberSaveRequest) { + String email = oauthMemberSaveRequest.getEmail(); + OauthProvider oauthProvider = OauthProvider.valueOfWithIgnoreCase(oauthMemberSaveRequest.getOauthProvider()); + + validateDuplicateEmail(email); + + Member member = new Member( + email, + oauthMemberSaveRequest.getOrganization(), + oauthProvider + ); + Member saveMember = members.save(member); + return MemberSaveResponse.from(saveMember); + } + @Transactional(readOnly = true) public void validateDuplicateEmail(final String email) { if (members.existsByEmail(email)) { throw new DuplicateEmailException(); } } + + public void updateMember(final Member member, final MemberUpdateRequest memberUpdateRequest) { + member.update(memberUpdateRequest.getOrganization()); + } + + public void deleteMember(final Member manager) { + boolean hasAnyReservations = reservations.existsReservationsByMemberFromToday(manager); + if (hasAnyReservations) { + throw new ReservationExistsOnMemberException(); + } + + members.delete(manager); + } } diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/service/ReservationService.java b/backend/src/main/java/com/woowacourse/zzimkkong/service/ReservationService.java index edd47144f..cb8816370 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/service/ReservationService.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/service/ReservationService.java @@ -29,8 +29,6 @@ @Service @Transactional public class ReservationService { - private static final long ONE_DAY = 1L; - private final MapRepository maps; private final ReservationRepository reservations; @@ -62,6 +60,7 @@ public ReservationCreateResponse saveReservation( Reservation.builder() .startTime(reservationCreateDto.getStartDateTime()) .endTime(reservationCreateDto.getEndDateTime()) + .date(reservationCreateDto.getStartDateTime().toLocalDate()) .password(reservationCreateDto.getPassword()) .userName(reservationCreateDto.getName()) .description(reservationCreateDto.getDescription()) @@ -157,6 +156,7 @@ public SlackResponse updateReservation( Reservation updateReservation = Reservation.builder() .startTime(reservationUpdateDto.getStartDateTime()) .endTime(reservationUpdateDto.getEndDateTime()) + .date(reservationUpdateDto.getStartDateTime().toLocalDate()) .userName(reservationUpdateDto.getName()) .description(reservationUpdateDto.getDescription()) .space(space) @@ -227,7 +227,7 @@ private void validateAvailability( private void validateSpaceSetting(Space space, LocalDateTime startDateTime, LocalDateTime endDateTime) { int durationMinutes = (int) ChronoUnit.MINUTES.between(startDateTime, endDateTime); - if (space.isIncorrectTimeUnit(startDateTime.getMinute()) | space.isNotDivideBy(durationMinutes)) { + if (space.isNotDivisibleByTimeUnit(startDateTime.getMinute()) || space.isNotDivisibleByTimeUnit(durationMinutes)) { throw new InvalidTimeUnitException(); } @@ -260,19 +260,11 @@ private void validateTimeConflicts( } private List getReservations(final Collection findSpaces, final LocalDate date) { - LocalDateTime minimumDateTime = date.atStartOfDay(); - LocalDateTime maximumDateTime = minimumDateTime.plusDays(ONE_DAY); List spaceIds = findSpaces.stream() .map(Space::getId) .collect(Collectors.toList()); - return reservations.findAllBySpaceIdInAndStartTimeIsBetweenAndEndTimeIsBetween( - spaceIds, - minimumDateTime, - maximumDateTime, - minimumDateTime, - maximumDateTime - ); + return reservations.findAllBySpaceIdInAndDate(spaceIds, date); } private void validateSpaceExistence(final Map map, final Long spaceId) { diff --git a/backend/src/main/resources/application-dev.properties b/backend/src/main/resources/application-dev.properties index 7195f0516..330af1ca3 100644 --- a/backend/src/main/resources/application-dev.properties +++ b/backend/src/main/resources/application-dev.properties @@ -1,6 +1,6 @@ # Database spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver -spring.datasource.url=jdbc:mysql://localhost:3306/zzimkkong?characterEncoding=UTF-8 +spring.datasource.url=jdbc:mysql://localhost:3306/zzimkkong?characterEncoding=UTF-8&useLegacyDatetimeCode=false spring.datasource.username=root spring.datasource.password=1234 spring.datasource.hikari.maximum-pool-size=45 @@ -13,6 +13,7 @@ spring.flyway.locations=classpath:db/migration/prod # jpa spring.jpa.hibernate.ddl-auto=validate +spring.jpa.properties.hibernate.jdbc.time_zone=Asia/Seoul # jwt jwt.token.secret-key=zzimkkong_secret_key_in_dev @@ -29,3 +30,6 @@ converter.temp.location=/home/ubuntu/zzimkkong/tmp/ # cors (delimiter == ',') cors.allow-origin.urls=* + +# oauth +google.uri.redirect=https://dev.zzimkkong.com/login/oauth/google diff --git a/backend/src/main/resources/application-local.properties b/backend/src/main/resources/application-local.properties index f82eb78ed..ef2f94a79 100644 --- a/backend/src/main/resources/application-local.properties +++ b/backend/src/main/resources/application-local.properties @@ -34,3 +34,7 @@ converter.temp.location=src/main/resources/tmp/ # cors (delimiter == ',') cors.allow-origin.urls=* + +# oauth +google.uri.redirect=http://localhost:3000/login/oauth/google + diff --git a/backend/src/main/resources/application-test.properties b/backend/src/main/resources/application-test.properties index 0be3af8c5..dc044cf16 100644 --- a/backend/src/main/resources/application-test.properties +++ b/backend/src/main/resources/application-test.properties @@ -26,3 +26,6 @@ converter.temp.location=src/main/resources/tmp/ # cors (delimiter == ',') cors.allow-origin.urls=* + +# oauth +google.uri.redirect=http://localhost:3000/login/oauth/google diff --git a/backend/src/main/resources/config b/backend/src/main/resources/config index 1eba39047..c0fefc075 160000 --- a/backend/src/main/resources/config +++ b/backend/src/main/resources/config @@ -1 +1 @@ -Subproject commit 1eba39047270b72c09b9b5e63af6e51bd7b6c8fa +Subproject commit c0fefc075ccdc956cce010686cf79f421ee9cd5c diff --git a/backend/src/main/resources/db/migration/prod/V10__db_indexing.sql b/backend/src/main/resources/db/migration/prod/V10__db_indexing.sql new file mode 100644 index 000000000..ef013672e --- /dev/null +++ b/backend/src/main/resources/db/migration/prod/V10__db_indexing.sql @@ -0,0 +1,6 @@ +ALTER TABLE reservation ADD COLUMN date date; +UPDATE reservation SET date = date(start_time); +ALTER TABLE reservation MODIFY COLUMN date date NOT NULL; + +CREATE INDEX date on reservation(date); +CREATE INDEX email on member(email); diff --git a/backend/src/main/resources/db/migration/prod/V9__oauth.sql b/backend/src/main/resources/db/migration/prod/V9__oauth.sql new file mode 100644 index 000000000..ae2ad0cc8 --- /dev/null +++ b/backend/src/main/resources/db/migration/prod/V9__oauth.sql @@ -0,0 +1,2 @@ +alter table member add column oauth_provider varchar(10); +alter table member modify column password varchar(128); diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/config/StringToOauthProviderConverterTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/config/StringToOauthProviderConverterTest.java new file mode 100644 index 000000000..695299096 --- /dev/null +++ b/backend/src/test/java/com/woowacourse/zzimkkong/config/StringToOauthProviderConverterTest.java @@ -0,0 +1,24 @@ +package com.woowacourse.zzimkkong.config; + +import com.woowacourse.zzimkkong.domain.OauthProvider; +import com.woowacourse.zzimkkong.infrastructure.oauth.StringToOauthProviderConverter; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class StringToOauthProviderConverterTest { + @Test + @DisplayName("Oauth 제공사 이름 문자열로부터 enum 객체를 찾을 수 있다.") + void convert() { + // given + String oauthProvider = "Github"; + + // when + StringToOauthProviderConverter stringToOauthProviderConverter = new StringToOauthProviderConverter(); + OauthProvider actual = stringToOauthProviderConverter.convert(oauthProvider); + + // then + assertThat(actual).isSameAs(OauthProvider.GITHUB); + } +} diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/controller/AcceptanceTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/controller/AcceptanceTest.java index d3dcc38ed..cb781ac60 100644 --- a/backend/src/test/java/com/woowacourse/zzimkkong/controller/AcceptanceTest.java +++ b/backend/src/test/java/com/woowacourse/zzimkkong/controller/AcceptanceTest.java @@ -6,6 +6,8 @@ import com.woowacourse.zzimkkong.dto.space.SettingsRequest; import com.woowacourse.zzimkkong.dto.space.SpaceCreateUpdateRequest; import com.woowacourse.zzimkkong.infrastructure.StorageUploader; +import com.woowacourse.zzimkkong.infrastructure.oauth.GithubRequester; +import com.woowacourse.zzimkkong.infrastructure.oauth.GoogleRequester; import io.restassured.RestAssured; import io.restassured.builder.RequestSpecBuilder; import io.restassured.specification.RequestSpecification; @@ -88,6 +90,12 @@ class AcceptanceTest { @Autowired protected PasswordEncoder passwordEncoder; + @MockBean + protected GithubRequester githubRequester; + + @MockBean + protected GoogleRequester googleRequester; + @BeforeEach void setUp(RestDocumentationContextProvider restDocumentation) { RestAssured.port = port; diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/controller/AuthControllerTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/controller/AuthControllerTest.java index 0f1525a09..a50f12048 100644 --- a/backend/src/test/java/com/woowacourse/zzimkkong/controller/AuthControllerTest.java +++ b/backend/src/test/java/com/woowacourse/zzimkkong/controller/AuthControllerTest.java @@ -1,6 +1,10 @@ package com.woowacourse.zzimkkong.controller; +import com.woowacourse.zzimkkong.domain.OauthProvider; +import com.woowacourse.zzimkkong.domain.oauth.GithubUserInfo; +import com.woowacourse.zzimkkong.domain.oauth.GoogleUserInfo; import com.woowacourse.zzimkkong.dto.member.LoginRequest; +import com.woowacourse.zzimkkong.dto.member.oauth.OauthMemberSaveRequest; import com.woowacourse.zzimkkong.dto.member.TokenResponse; import com.woowacourse.zzimkkong.infrastructure.AuthorizationExtractor; import io.restassured.RestAssured; @@ -11,9 +15,17 @@ import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import java.util.Map; + +import static com.woowacourse.zzimkkong.Constants.NEW_EMAIL; +import static com.woowacourse.zzimkkong.Constants.ORGANIZATION; import static com.woowacourse.zzimkkong.DocumentUtils.*; import static com.woowacourse.zzimkkong.controller.MemberControllerTest.saveMember; +import static com.woowacourse.zzimkkong.controller.MemberControllerTest.saveMemberByOauth; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; import static org.springframework.restdocs.restassured3.RestAssuredRestDocumentation.document; class AuthControllerTest extends AcceptanceTest { @@ -32,6 +44,53 @@ void login() { assertThat(responseBody.getAccessToken()).isInstanceOf(String.class); } + @Test + @DisplayName("Github Oauth 로그인 요청이 오면 토큰을 발급한다.") + void loginByGithubOauth() { + // given + OauthProvider oauthProvider = OauthProvider.GITHUB; + saveMemberByOauth(new OauthMemberSaveRequest(NEW_EMAIL, ORGANIZATION, oauthProvider.name())); + String code = "example-code"; + + given(githubRequester.supports(OauthProvider.GITHUB)) + .willReturn(true); + given(githubRequester.getUserInfoByCode(code)) + .willReturn(GithubUserInfo.from(Map.of("email", NEW_EMAIL))); + + // when + ExtractableResponse response = loginByOauth(oauthProvider, code); + TokenResponse responseBody = response.body().as(TokenResponse.class); + + // then + assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value()); + assertThat(responseBody.getAccessToken()).isInstanceOf(String.class); + + } + + @Test + @DisplayName("Google Oauth 로그인 요청이 오면 토큰을 발급한다.") + void loginByGoogleOauth() { + // given + OauthProvider oauthProvider = OauthProvider.GOOGLE; + saveMemberByOauth(new OauthMemberSaveRequest(NEW_EMAIL, ORGANIZATION, oauthProvider.name())); + String code = "example-code"; + + given(googleRequester.supports(any(OauthProvider.class))) + .willReturn(true); + given(googleRequester.getUserInfoByCode(anyString())) + .willReturn(GoogleUserInfo.from( + Map.of("id", "123", + "email", NEW_EMAIL))); + + // when + ExtractableResponse response = loginByOauth(oauthProvider, code); + TokenResponse responseBody = response.body().as(TokenResponse.class); + + // then + assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value()); + assertThat(responseBody.getAccessToken()).isInstanceOf(String.class); + } + @Test @DisplayName("유효한 토큰으로 요청이 오면 200 ok가 반환된다.") void validToken() { @@ -73,7 +132,17 @@ static ExtractableResponse login(final LoginRequest loginRequest) { .filter(document("member/login", getRequestPreprocessor(), getResponsePreprocessor())) .contentType(MediaType.APPLICATION_JSON_VALUE) .body(loginRequest) - .when().post("/api/login/token") + .when().post("/api/managers/login/token") + .then().log().all().extract(); + } + + static ExtractableResponse loginByOauth(final OauthProvider oauthProvider, final String code) { + return RestAssured + .given(getRequestSpecification()).log().all() + .accept("application/json") + .filter(document("member/login/oauth/" + oauthProvider.name(), getRequestPreprocessor(), getResponsePreprocessor())) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .when().get("/api/managers/" + oauthProvider + "/login/token?code=" + code) .then().log().all().extract(); } @@ -84,7 +153,7 @@ private ExtractableResponse token(final String token, final String doc .header("Authorization", token) .filter(document("member/token/" + docName, getRequestPreprocessor(), getResponsePreprocessor())) .contentType(MediaType.APPLICATION_JSON_VALUE) - .when().post("/api/members/token") + .when().post("/api/managers/token") .then().log().all().extract(); } } diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/controller/ManagerSpaceControllerTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/controller/ManagerSpaceControllerTest.java index 3a17fd902..cadce586c 100644 --- a/backend/src/test/java/com/woowacourse/zzimkkong/controller/ManagerSpaceControllerTest.java +++ b/backend/src/test/java/com/woowacourse/zzimkkong/controller/ManagerSpaceControllerTest.java @@ -92,7 +92,7 @@ void save() { LocalTime.of(20, 0), 30, 60, - 100, + 120, true, "monday, tuesday, wednesday, thursday, friday, saturday, sunday" ); @@ -141,7 +141,7 @@ void save_default() { .availableEndTime(LocalTime.of(18, 0)) .reservationTimeUnit(10) .reservationMinimumTimeUnit(10) - .reservationMaximumTimeUnit(1440) + .reservationMaximumTimeUnit(120) .reservationEnable(true) .enabledDayOfWeek("monday, tuesday, wednesday, thursday, friday, saturday, sunday") .build(); @@ -207,9 +207,9 @@ void update() { SettingsRequest settingsRequest = new SettingsRequest( LocalTime.of(10, 0), LocalTime.of(22, 0), - 40, - 80, - 130, + 30, + 60, + 120, false, "monday, tuesday, wednesday, thursday, friday, saturday, sunday" ); diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/controller/MemberControllerTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/controller/MemberControllerTest.java index f033aa5fe..ce311c4c1 100644 --- a/backend/src/test/java/com/woowacourse/zzimkkong/controller/MemberControllerTest.java +++ b/backend/src/test/java/com/woowacourse/zzimkkong/controller/MemberControllerTest.java @@ -1,11 +1,13 @@ package com.woowacourse.zzimkkong.controller; import com.woowacourse.zzimkkong.domain.Member; +import com.woowacourse.zzimkkong.domain.OauthProvider; import com.woowacourse.zzimkkong.domain.Preset; import com.woowacourse.zzimkkong.domain.Setting; -import com.woowacourse.zzimkkong.dto.member.MemberSaveRequest; -import com.woowacourse.zzimkkong.dto.member.PresetFindAllResponse; -import com.woowacourse.zzimkkong.dto.member.PresetCreateRequest; +import com.woowacourse.zzimkkong.dto.ErrorResponse; +import com.woowacourse.zzimkkong.dto.InputFieldErrorResponse; +import com.woowacourse.zzimkkong.dto.member.*; +import com.woowacourse.zzimkkong.dto.member.oauth.OauthMemberSaveRequest; import com.woowacourse.zzimkkong.dto.space.SettingsRequest; import com.woowacourse.zzimkkong.infrastructure.AuthorizationExtractor; import io.restassured.RestAssured; @@ -14,10 +16,10 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; -import org.springframework.security.crypto.password.PasswordEncoder; import java.util.List; @@ -71,6 +73,20 @@ void join() { assertThat(response.statusCode()).isEqualTo(HttpStatus.CREATED.value()); } + @ParameterizedTest + @ValueSource(strings = {"GOOGLE", "GITHUB"}) + @DisplayName("Oauth을 이용해 회원가입한다.") + void joinByOauth(String oauth) { + // given + OauthMemberSaveRequest oauthMemberSaveRequest = new OauthMemberSaveRequest(NEW_EMAIL, ORGANIZATION, oauth); + + // when + ExtractableResponse response = saveMemberByOauth(oauthMemberSaveRequest); + + //then + assertThat(response.statusCode()).isEqualTo(HttpStatus.CREATED.value()); + } + @Test @DisplayName("이메일 중복 확인 시, 중복되지 않은 이메일을 입력하면 통과한다.") void getMembers() { @@ -131,6 +147,51 @@ void delete() { assertThat(response.statusCode()).isEqualTo(HttpStatus.NO_CONTENT.value()); } + @Test + @DisplayName("유저는 자신의 정보를 조회할 수 있다.") + void findMe() { + // given, when + ExtractableResponse response = findMyInfo(); + + MemberFindResponse actual = response.as(MemberFindResponse.class); + MemberFindResponse expected = MemberFindResponse.from(pobi); + + // then + assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value()); + assertThat(actual).usingRecursiveComparison() + .ignoringFields("id") + .isEqualTo(expected); + } + + @Test + @DisplayName("유저는 자신의 정보를 수정할 수 있다.") + void updateMe() { + // given + MemberUpdateRequest memberUpdateRequest = new MemberUpdateRequest("woowabros"); + + // when + ExtractableResponse response = updateMyInfo(memberUpdateRequest); + + // then + MemberFindResponse afterUpdate = findMyInfo().as(MemberFindResponse.class); + assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value()); + assertThat(afterUpdate.getOrganization()).isEqualTo("woowabros"); + } + + @Test + @DisplayName("유저는 회원 탈퇴할 수 있다.") + void deleteMe() { + // given, when + ExtractableResponse response = deleteMyInfo(); + ExtractableResponse errorExpectedResponse = findMyInfo(); + ErrorResponse errorResponse = errorExpectedResponse.as(InputFieldErrorResponse.class); + + // then + assertThat(response.statusCode()).isEqualTo(HttpStatus.NO_CONTENT.value()); + assertThat(errorExpectedResponse.statusCode()).isEqualTo(HttpStatus.NOT_FOUND.value()); + assertThat(errorResponse.getMessage()).isNotEmpty(); + } + static ExtractableResponse saveMember(final MemberSaveRequest memberSaveRequest) { return RestAssured .given(getRequestSpecification()).log().all() @@ -138,7 +199,28 @@ static ExtractableResponse saveMember(final MemberSaveRequest memberSa .filter(document("member/post", getRequestPreprocessor(), getResponsePreprocessor())) .contentType(MediaType.APPLICATION_JSON_VALUE) .body(memberSaveRequest) - .when().post("/api/members") + .when().post("/api/managers") + .then().log().all().extract(); + } + + static ExtractableResponse getReadyToJoin(final OauthProvider oauthProvider, final String code) { + return RestAssured + .given(getRequestSpecification()).log().all() + .accept("application/json") + .filter(document("member/get/oauth/" + oauthProvider.name(), getRequestPreprocessor(), getResponsePreprocessor())) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .when().get("/api/managers/" + oauthProvider + "?code=" + code) + .then().log().all().extract(); + } + + static ExtractableResponse saveMemberByOauth(final OauthMemberSaveRequest oauthMemberSaveRequest) { + return RestAssured + .given(getRequestSpecification()).log().all() + .accept("application/json") + .filter(document("member/post/oauth/" + oauthMemberSaveRequest.getOauthProvider(), getRequestPreprocessor(), getResponsePreprocessor())) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(oauthMemberSaveRequest) + .when().post("/api/managers/oauth") .then().log().all().extract(); } @@ -149,7 +231,7 @@ private ExtractableResponse validateDuplicateEmail(final String email) .filter(document("member/get", getRequestPreprocessor(), getResponsePreprocessor())) .queryParam("email", email) .contentType(MediaType.APPLICATION_JSON_VALUE) - .when().get("/api/members") + .when().get("/api/managers") .then().log().all().extract(); } @@ -161,7 +243,7 @@ private ExtractableResponse savePreset(final PresetCreateRequest prese .filter(document("preset/post", getRequestPreprocessor(), getResponsePreprocessor())) .contentType(MediaType.APPLICATION_JSON_VALUE) .body(presetCreateRequest) - .when().post("/api/members/presets") + .when().post("/api/managers/presets") .then().log().all().extract(); } @@ -172,7 +254,7 @@ private ExtractableResponse findAllPresets() { .header("Authorization", AuthorizationExtractor.AUTHENTICATION_TYPE + " " + accessToken) .filter(document("preset/getAll", getRequestPreprocessor(), getResponsePreprocessor())) .contentType(MediaType.APPLICATION_JSON_VALUE) - .when().get("/api/members/presets") + .when().get("/api/managers/presets") .then().log().all().extract(); } @@ -186,4 +268,38 @@ private ExtractableResponse deletePreset(String api) { .when().delete(api) .then().log().all().extract(); } + + private ExtractableResponse findMyInfo() { + return RestAssured + .given(getRequestSpecification()).log().all() + .accept("application/json") + .header("Authorization", AuthorizationExtractor.AUTHENTICATION_TYPE + " " + accessToken) + .filter(document("member/myinfo/get", getRequestPreprocessor(), getResponsePreprocessor())) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .when().get("/api/managers/me") + .then().log().all().extract(); + } + + private ExtractableResponse updateMyInfo(MemberUpdateRequest memberUpdateRequest) { + return RestAssured + .given(getRequestSpecification()).log().all() + .accept("application/json") + .header("Authorization", AuthorizationExtractor.AUTHENTICATION_TYPE + " " + accessToken) + .filter(document("member/myinfo/put", getRequestPreprocessor(), getResponsePreprocessor())) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(memberUpdateRequest) + .when().put("/api/managers/me") + .then().log().all().extract(); + } + + private ExtractableResponse deleteMyInfo() { + return RestAssured + .given(getRequestSpecification()).log().all() + .accept("application/json") + .header("Authorization", AuthorizationExtractor.AUTHENTICATION_TYPE + " " + accessToken) + .filter(document("member/myinfo/delete", getRequestPreprocessor(), getResponsePreprocessor())) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .when().delete("/api/managers/me") + .then().log().all().extract(); + } } diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/domain/MapTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/domain/MapTest.java index eebb7135b..5bea290f4 100644 --- a/backend/src/test/java/com/woowacourse/zzimkkong/domain/MapTest.java +++ b/backend/src/test/java/com/woowacourse/zzimkkong/domain/MapTest.java @@ -2,6 +2,8 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import static com.woowacourse.zzimkkong.Constants.*; import static org.assertj.core.api.Assertions.assertThat; @@ -29,4 +31,20 @@ void isNotOwnedBy() { boolean result = luther.isNotOwnedBy(new Member("삭정이", "test1234", "잠실")); assertThat(result).isTrue(); } + + @ParameterizedTest + @DisplayName("생성자 인자에 주어지는 Member가 null이 아니라면 Member의 maps에 Map이 추가된다.") + @CsvSource({"true", "false"}) + void addMap(boolean nullable) { + Map luther; + if (nullable) { + luther = new Map(LUTHER_NAME, MAP_DRAWING_DATA, MAP_IMAGE_URL, null); + assertThat(luther.getMember()).isNull(); + return; + } + Member pobi = new Member(EMAIL, PW, ORGANIZATION); + luther = new Map(LUTHER_NAME, MAP_DRAWING_DATA, MAP_IMAGE_URL, pobi); + + assertThat(pobi.getMaps()).contains(luther); + } } diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/domain/SettingTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/domain/SettingTest.java new file mode 100644 index 000000000..9d47b6ce1 --- /dev/null +++ b/backend/src/test/java/com/woowacourse/zzimkkong/domain/SettingTest.java @@ -0,0 +1,117 @@ +package com.woowacourse.zzimkkong.domain; + +import com.woowacourse.zzimkkong.exception.space.*; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import java.time.LocalTime; + +import static com.woowacourse.zzimkkong.Constants.*; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +class SettingTest { + @Test + @DisplayName("setting의 입력값이 모두 올바르면 setting을 생성한다") + void name() { + assertDoesNotThrow(() -> Setting.builder() + .availableStartTime(FE_AVAILABLE_START_TIME) + .availableEndTime(FE_AVAILABLE_END_TIME) + .reservationTimeUnit(FE_RESERVATION_TIME_UNIT) + .reservationMinimumTimeUnit(FE_RESERVATION_MINIMUM_TIME_UNIT) + .reservationMaximumTimeUnit(FE_RESERVATION_MAXIMUM_TIME_UNIT) + .reservationEnable(FE_RESERVATION_ENABLE) + .enabledDayOfWeek(FE_ENABLED_DAY_OF_WEEK) + .build()); + } + + @ParameterizedTest + @CsvSource(value = {"10,9", "10,10"}) + @DisplayName("setting 생성 시 예약이 열릴 시간이 예약 닫힐 시간 이후거나 같으면 예외를 던진다") + void invalidAvailableStartEndTime(int availableStartTimeHour, int availableEndTimeHour) { + final Setting.SettingBuilder settingBuilder = Setting.builder() + .availableStartTime(LocalTime.of(availableStartTimeHour, 0)) + .availableEndTime(LocalTime.of(availableEndTimeHour, 0)) + .reservationTimeUnit(FE_RESERVATION_TIME_UNIT) + .reservationMinimumTimeUnit(FE_RESERVATION_MINIMUM_TIME_UNIT) + .reservationMaximumTimeUnit(FE_RESERVATION_MAXIMUM_TIME_UNIT) + .reservationEnable(FE_RESERVATION_ENABLE) + .enabledDayOfWeek(FE_ENABLED_DAY_OF_WEEK); + assertThatThrownBy(settingBuilder::build).isInstanceOf(ImpossibleAvailableStartEndTimeException.class); + } + + @Test + @DisplayName("setting 생성 시 최대 예약 가능 시간이 최소 예약 가능시간 보다 작으면 예외를 던진다") + void invalidMinimumMaximumTimeUnit() { + final Setting.SettingBuilder settingBuilder = Setting.builder() + .availableStartTime(FE_AVAILABLE_START_TIME) + .availableEndTime(FE_AVAILABLE_END_TIME) + .reservationTimeUnit(FE_RESERVATION_TIME_UNIT) + .reservationMinimumTimeUnit(20) + .reservationMaximumTimeUnit(10) + .reservationEnable(FE_RESERVATION_ENABLE) + .enabledDayOfWeek(FE_ENABLED_DAY_OF_WEEK); + assertThatThrownBy(settingBuilder::build).isInstanceOf(InvalidMinimumMaximumTimeUnitException.class); + } + + @Test + @DisplayName("setting 생성 시 예약이 가능한 시간 범위가 최대 예약 가능 시간 보다 작으면 예외를 던진다") + void notEnoughTimeAvailable() { + final Setting.SettingBuilder settingBuilder = Setting.builder() + .availableStartTime(LocalTime.of(10, 0)) + .availableEndTime(LocalTime.of(11, 0)) + .reservationTimeUnit(FE_RESERVATION_TIME_UNIT) + .reservationMinimumTimeUnit(FE_RESERVATION_MINIMUM_TIME_UNIT) + .reservationMaximumTimeUnit(70) + .reservationEnable(FE_RESERVATION_ENABLE) + .enabledDayOfWeek(FE_ENABLED_DAY_OF_WEEK); + assertThatThrownBy(settingBuilder::build).isInstanceOf(NotEnoughAvailableTimeException.class); + } + + @ParameterizedTest + @CsvSource(value = {"30,50,10", "0,0,10", "10,15,5", "0,5,5", "0,30,30", "0,0,60"}) + @DisplayName("setting 생성 시 예약이 시작되는 시간과 닫히는 시간이 time unit단위와 맞으면 예외를 던지지 않는다") + void timeUnitMismatch_ok(int startMinute, int endMinute, int timeUnit) { + assertDoesNotThrow(() -> Setting.builder() + .availableStartTime(LocalTime.of(10, startMinute)) + .availableEndTime(LocalTime.of(20, endMinute)) + .reservationTimeUnit(timeUnit) + .reservationMinimumTimeUnit(FE_RESERVATION_MINIMUM_TIME_UNIT) + .reservationMaximumTimeUnit(FE_RESERVATION_MAXIMUM_TIME_UNIT) + .reservationEnable(FE_RESERVATION_ENABLE) + .enabledDayOfWeek(FE_ENABLED_DAY_OF_WEEK) + .build()); + } + + @ParameterizedTest + @CsvSource(value = {"22,27,5", "0,15,10", "15,0,10", "10,40,30", "5,5,60"}) + @DisplayName("setting 생성 시 예약이 시작되는 시간과 닫히는 시간이 time unit단위와 맞지 않으면 예외를 던진다") + void timeUnitMismatch_fail(int startMinute, int endMinute, int timeUnit) { + final Setting.SettingBuilder settingBuilder = Setting.builder() + .availableStartTime(LocalTime.of(10, startMinute)) + .availableEndTime(LocalTime.of(20, endMinute)) + .reservationTimeUnit(timeUnit) + .reservationMinimumTimeUnit(FE_RESERVATION_MINIMUM_TIME_UNIT) + .reservationMaximumTimeUnit(FE_RESERVATION_MAXIMUM_TIME_UNIT) + .reservationEnable(FE_RESERVATION_ENABLE) + .enabledDayOfWeek(FE_ENABLED_DAY_OF_WEEK); + assertThatThrownBy(settingBuilder::build).isInstanceOf(TimeUnitMismatchException.class); + } + + @ParameterizedTest + @CsvSource(value = {"0,5", "5,10", "9,20", "10,25", "15,30", "25,45"}) + @DisplayName("setting 생성 시 최소,최대 예약 가능 시간의 단위가 예약 시간 단위와 일치하지 않으면 예외를 던진다") + void timeUnitInconsistency_fail(int minimumMinute, int maximumMinute) { + final Setting.SettingBuilder settingBuilder = Setting.builder() + .availableStartTime(FE_AVAILABLE_START_TIME) + .availableEndTime(FE_AVAILABLE_END_TIME) + .reservationTimeUnit(10) + .reservationMinimumTimeUnit(minimumMinute) + .reservationMaximumTimeUnit(maximumMinute) + .reservationEnable(FE_RESERVATION_ENABLE) + .enabledDayOfWeek(FE_ENABLED_DAY_OF_WEEK); + assertThatThrownBy(settingBuilder::build).isInstanceOf(TimeUnitInconsistencyException.class); + } +} diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/domain/SpaceTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/domain/SpaceTest.java index b9dba4275..fd2dfdbe5 100644 --- a/backend/src/test/java/com/woowacourse/zzimkkong/domain/SpaceTest.java +++ b/backend/src/test/java/com/woowacourse/zzimkkong/domain/SpaceTest.java @@ -3,6 +3,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.EnumSource; import org.junit.jupiter.params.provider.ValueSource; @@ -22,6 +23,11 @@ void update() { Setting setting = Setting.builder() .availableStartTime(LocalTime.of(10, 0)) .availableEndTime(LocalTime.of(18, 0)) + .reservationTimeUnit(FE_RESERVATION_TIME_UNIT) + .reservationMinimumTimeUnit(FE_RESERVATION_MINIMUM_TIME_UNIT) + .reservationMaximumTimeUnit(FE_RESERVATION_MAXIMUM_TIME_UNIT) + .reservationEnable(FE_RESERVATION_ENABLE) + .enabledDayOfWeek(FE_ENABLED_DAY_OF_WEEK) .build(); Space space = Space.builder() .name("와우") @@ -35,7 +41,11 @@ void update() { Setting updateSetting = Setting.builder() .availableStartTime(LocalTime.of(10, 0)) .availableEndTime(LocalTime.of(18, 0)) - .reservationEnable(true) + .reservationTimeUnit(FE_RESERVATION_TIME_UNIT) + .reservationMinimumTimeUnit(FE_RESERVATION_MINIMUM_TIME_UNIT) + .reservationMaximumTimeUnit(FE_RESERVATION_MAXIMUM_TIME_UNIT) + .reservationEnable(false) + .enabledDayOfWeek(FE_ENABLED_DAY_OF_WEEK) .build(); Space updateSpace = Space.builder() .name("우와") @@ -61,17 +71,23 @@ void hasSameId() { assertThat(space.hasSameId(space.getId() + 1)).isFalse(); } - @Test + @ParameterizedTest + @CsvSource(value = {"10,12", "17,18"}) @DisplayName("예약하려는 시간이 공간의 예약 가능한 시간 내에 있다면 false를 반환한다") - void isNotBetweenAvailableTime() { + void isNotBetweenAvailableTime(int startHour, int endHour) { Setting availableTimeSetting = Setting.builder() .availableStartTime(LocalTime.of(10, 0)) .availableEndTime(LocalTime.of(18, 0)) + .reservationTimeUnit(FE_RESERVATION_TIME_UNIT) + .reservationMinimumTimeUnit(FE_RESERVATION_MINIMUM_TIME_UNIT) + .reservationMaximumTimeUnit(FE_RESERVATION_MAXIMUM_TIME_UNIT) + .reservationEnable(FE_RESERVATION_ENABLE) + .enabledDayOfWeek(FE_ENABLED_DAY_OF_WEEK) .build(); Space availableTimeSpace = Space.builder().setting(availableTimeSetting).build(); - LocalDateTime startDateTime = THE_DAY_AFTER_TOMORROW.atTime(10, 0); - LocalDateTime endDateTime = THE_DAY_AFTER_TOMORROW.atTime(18, 0); + LocalDateTime startDateTime = THE_DAY_AFTER_TOMORROW.atTime(startHour, 0); + LocalDateTime endDateTime = THE_DAY_AFTER_TOMORROW.atTime(endHour, 0); boolean actual = availableTimeSpace.isNotBetweenAvailableTime(startDateTime, endDateTime); assertThat(actual).isFalse(); @@ -83,6 +99,11 @@ void isNotBetweenAvailableTimeFail() { Setting availableTimeSetting = Setting.builder() .availableStartTime(LocalTime.of(10, 0)) .availableEndTime(LocalTime.of(18, 0)) + .reservationTimeUnit(FE_RESERVATION_TIME_UNIT) + .reservationMinimumTimeUnit(FE_RESERVATION_MINIMUM_TIME_UNIT) + .reservationMaximumTimeUnit(FE_RESERVATION_MAXIMUM_TIME_UNIT) + .reservationEnable(FE_RESERVATION_ENABLE) + .enabledDayOfWeek(FE_ENABLED_DAY_OF_WEEK) .build(); Space availableTimeSpace = Space.builder().setting(availableTimeSetting).build(); @@ -98,11 +119,17 @@ void isNotBetweenAvailableTimeFail() { @DisplayName("예약 시작 시간의 단위가 타당하면 false를 반환한다.") void isCorrectTimeUnit(int minute) { Setting availableTimeSetting = Setting.builder() + .availableStartTime(FE_AVAILABLE_START_TIME) + .availableEndTime(FE_AVAILABLE_END_TIME) .reservationTimeUnit(10) + .reservationMinimumTimeUnit(FE_RESERVATION_MINIMUM_TIME_UNIT) + .reservationMaximumTimeUnit(FE_RESERVATION_MAXIMUM_TIME_UNIT) + .reservationEnable(FE_RESERVATION_ENABLE) + .enabledDayOfWeek(FE_ENABLED_DAY_OF_WEEK) .build(); Space availableTimeSpace = Space.builder().setting(availableTimeSetting).build(); - boolean actual = availableTimeSpace.isIncorrectTimeUnit(minute); + boolean actual = availableTimeSpace.isNotDivisibleByTimeUnit(minute); assertThat(actual).isFalse(); } @@ -112,11 +139,17 @@ void isCorrectTimeUnit(int minute) { @DisplayName("예약 시작 시간의 단위가 타당하지 않다면 true를 반환한다.") void isCorrectTimeUnitFail(int minute) { Setting availableTimeSetting = Setting.builder() + .availableStartTime(FE_AVAILABLE_START_TIME) + .availableEndTime(FE_AVAILABLE_END_TIME) .reservationTimeUnit(10) + .reservationMinimumTimeUnit(FE_RESERVATION_MINIMUM_TIME_UNIT) + .reservationMaximumTimeUnit(FE_RESERVATION_MAXIMUM_TIME_UNIT) + .reservationEnable(FE_RESERVATION_ENABLE) + .enabledDayOfWeek(FE_ENABLED_DAY_OF_WEEK) .build(); Space availableTimeSpace = Space.builder().setting(availableTimeSetting).build(); - boolean actual = availableTimeSpace.isIncorrectTimeUnit(minute); + boolean actual = availableTimeSpace.isNotDivisibleByTimeUnit(minute); assertThat(actual).isTrue(); } @@ -126,8 +159,13 @@ void isCorrectTimeUnitFail(int minute) { @DisplayName("예약 시간의 단위가 최소최대 예약시간단위 내에 있다면 false를 반환한다.") void isCorrectMinimumMaximumTimeUnit(int durationMinutes) { Setting availableTimeSetting = Setting.builder() + .availableStartTime(FE_AVAILABLE_START_TIME) + .availableEndTime(FE_AVAILABLE_END_TIME) + .reservationTimeUnit(FE_RESERVATION_TIME_UNIT) .reservationMinimumTimeUnit(10) .reservationMaximumTimeUnit(120) + .reservationEnable(FE_RESERVATION_ENABLE) + .enabledDayOfWeek(FE_ENABLED_DAY_OF_WEEK) .build(); Space availableTimeSpace = Space.builder().setting(availableTimeSetting).build(); @@ -141,8 +179,13 @@ void isCorrectMinimumMaximumTimeUnit(int durationMinutes) { @DisplayName("예약 시간의 단위가 최소시간단위보다 작거나 최대시간단위보다 크다면 true를 반환한다.") void isCorrectMinimumMaximumTimeUnitFail(int durationMinutes) { Setting availableTimeSetting = Setting.builder() + .availableStartTime(FE_AVAILABLE_START_TIME) + .availableEndTime(FE_AVAILABLE_END_TIME) + .reservationTimeUnit(FE_RESERVATION_TIME_UNIT) .reservationMinimumTimeUnit(10) .reservationMaximumTimeUnit(120) + .reservationEnable(FE_RESERVATION_ENABLE) + .enabledDayOfWeek(FE_ENABLED_DAY_OF_WEEK) .build(); Space availableTimeSpace = Space.builder().setting(availableTimeSetting).build(); @@ -155,12 +198,18 @@ void isCorrectMinimumMaximumTimeUnitFail(int durationMinutes) { @DisplayName("예약 시간의 단위가 공간의 timeUnit으로 나누어떨어지면 false를 반환한다.") void isNotDivideBy() { Setting availableTimeSetting = Setting.builder() + .availableStartTime(FE_AVAILABLE_START_TIME) + .availableEndTime(FE_AVAILABLE_END_TIME) .reservationTimeUnit(10) + .reservationMinimumTimeUnit(FE_RESERVATION_MINIMUM_TIME_UNIT) + .reservationMaximumTimeUnit(FE_RESERVATION_MAXIMUM_TIME_UNIT) + .reservationEnable(FE_RESERVATION_ENABLE) + .enabledDayOfWeek(FE_ENABLED_DAY_OF_WEEK) .build(); Space availableTimeSpace = Space.builder().setting(availableTimeSetting).build(); int minute = 100; - boolean actual = availableTimeSpace.isNotDivideBy(minute); + boolean actual = availableTimeSpace.isNotDivisibleByTimeUnit(minute); assertThat(actual).isFalse(); } @@ -169,12 +218,18 @@ void isNotDivideBy() { @DisplayName("예약 시간의 단위가 공간의 timeUnit으로 나누어떨어지지 않으면 true를 반환한다.") void isNotDivideByFail() { Setting availableTimeSetting = Setting.builder() + .availableStartTime(FE_AVAILABLE_START_TIME) + .availableEndTime(FE_AVAILABLE_END_TIME) .reservationTimeUnit(10) + .reservationMinimumTimeUnit(FE_RESERVATION_MINIMUM_TIME_UNIT) + .reservationMaximumTimeUnit(FE_RESERVATION_MAXIMUM_TIME_UNIT) + .reservationEnable(FE_RESERVATION_ENABLE) + .enabledDayOfWeek(FE_ENABLED_DAY_OF_WEEK) .build(); Space availableTimeSpace = Space.builder().setting(availableTimeSetting).build(); int minute = 12; - boolean actual = availableTimeSpace.isNotDivideBy(minute); + boolean actual = availableTimeSpace.isNotDivisibleByTimeUnit(minute); assertThat(actual).isTrue(); } @@ -182,7 +237,15 @@ void isNotDivideByFail() { @Test @DisplayName("예약이 가능한 공간이면 false를 반환한다") void isUnableToReserve() { - Setting reservationEnableSetting = Setting.builder().reservationEnable(true).build(); + Setting reservationEnableSetting = Setting.builder() + .availableStartTime(FE_AVAILABLE_START_TIME) + .availableEndTime(FE_AVAILABLE_END_TIME) + .reservationTimeUnit(FE_RESERVATION_TIME_UNIT) + .reservationMinimumTimeUnit(FE_RESERVATION_MINIMUM_TIME_UNIT) + .reservationMaximumTimeUnit(FE_RESERVATION_MAXIMUM_TIME_UNIT) + .reservationEnable(true) + .enabledDayOfWeek(FE_ENABLED_DAY_OF_WEEK) + .build(); Space reservationEnableSpace = Space.builder().setting(reservationEnableSetting).build(); assertThat(reservationEnableSpace.isUnableToReserve()).isFalse(); @@ -191,7 +254,15 @@ void isUnableToReserve() { @Test @DisplayName("예약이 불가능한 공간이면 true를 반환한다") void isUnableToReserveFail() { - Setting reservationUnableSetting = Setting.builder().reservationEnable(false).build(); + Setting reservationUnableSetting = Setting.builder() + .availableStartTime(FE_AVAILABLE_START_TIME) + .availableEndTime(FE_AVAILABLE_END_TIME) + .reservationTimeUnit(FE_RESERVATION_TIME_UNIT) + .reservationMinimumTimeUnit(FE_RESERVATION_MINIMUM_TIME_UNIT) + .reservationMaximumTimeUnit(FE_RESERVATION_MAXIMUM_TIME_UNIT) + .reservationEnable(false) + .enabledDayOfWeek(FE_ENABLED_DAY_OF_WEEK) + .build(); Space reservationUnableSpace = Space.builder().setting(reservationUnableSetting).build(); assertThat(reservationUnableSpace.isUnableToReserve()).isTrue(); @@ -201,7 +272,15 @@ void isUnableToReserveFail() { @EnumSource(value = DayOfWeek.class, names = {"MONDAY", "WEDNESDAY"}) @DisplayName("해당 요일에 예약이 가능하면 false를 반환한다") void isClosedOn(DayOfWeek dayOfWeek) { - Setting setting = Setting.builder().enabledDayOfWeek("monday, wednesday").build(); + Setting setting = Setting.builder() + .availableStartTime(FE_AVAILABLE_START_TIME) + .availableEndTime(FE_AVAILABLE_END_TIME) + .reservationTimeUnit(FE_RESERVATION_TIME_UNIT) + .reservationMinimumTimeUnit(FE_RESERVATION_MINIMUM_TIME_UNIT) + .reservationMaximumTimeUnit(FE_RESERVATION_MAXIMUM_TIME_UNIT) + .reservationEnable(FE_RESERVATION_ENABLE) + .enabledDayOfWeek("monday, wednesday") + .build(); Space space = Space.builder().setting(setting).build(); assertThat(space.isClosedOn(dayOfWeek)).isFalse(); @@ -211,7 +290,15 @@ void isClosedOn(DayOfWeek dayOfWeek) { @EnumSource(value = DayOfWeek.class, names = {"TUESDAY", "THURSDAY", "FRIDAY", "SATURDAY", "SUNDAY"}) @DisplayName("해당 요일에 예약이 불가능하면 true를 반환한다") void isClosedOnFail(DayOfWeek dayOfWeek) { - Setting setting = Setting.builder().enabledDayOfWeek("monday, wednesday").build(); + Setting setting = Setting.builder() + .availableStartTime(FE_AVAILABLE_START_TIME) + .availableEndTime(FE_AVAILABLE_END_TIME) + .reservationTimeUnit(FE_RESERVATION_TIME_UNIT) + .reservationMinimumTimeUnit(FE_RESERVATION_MINIMUM_TIME_UNIT) + .reservationMaximumTimeUnit(FE_RESERVATION_MAXIMUM_TIME_UNIT) + .reservationEnable(FE_RESERVATION_ENABLE) + .enabledDayOfWeek("monday, wednesday") + .build(); Space space = Space.builder().setting(setting).build(); assertThat(space.isClosedOn(dayOfWeek)).isTrue(); @@ -221,7 +308,15 @@ void isClosedOnFail(DayOfWeek dayOfWeek) { @EnumSource(value = DayOfWeek.class) @DisplayName("예약 가능한 요일이 null이면 모든 요일에 대해서 true를 반환한다") void isClosedOn_nullEnabledDayOfWeek(DayOfWeek dayOfWeek) { - Setting setting = Setting.builder().enabledDayOfWeek(null).build(); + Setting setting = Setting.builder() + .availableStartTime(FE_AVAILABLE_START_TIME) + .availableEndTime(FE_AVAILABLE_END_TIME) + .reservationTimeUnit(FE_RESERVATION_TIME_UNIT) + .reservationMinimumTimeUnit(FE_RESERVATION_MINIMUM_TIME_UNIT) + .reservationMaximumTimeUnit(FE_RESERVATION_MAXIMUM_TIME_UNIT) + .reservationEnable(FE_RESERVATION_ENABLE) + .enabledDayOfWeek(null) + .build(); Space space = Space.builder().setting(setting).build(); assertThat(space.isClosedOn(dayOfWeek)).isTrue(); diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/domain/oauth/GithubUserInfoTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/domain/oauth/GithubUserInfoTest.java new file mode 100644 index 000000000..c533e347f --- /dev/null +++ b/backend/src/test/java/com/woowacourse/zzimkkong/domain/oauth/GithubUserInfoTest.java @@ -0,0 +1,40 @@ +package com.woowacourse.zzimkkong.domain.oauth; + +import com.woowacourse.zzimkkong.exception.infrastructure.oauth.NoPublicEmailOnGithubException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class GithubUserInfoTest { + @Test + @DisplayName("map으로 받아온 정보로부터 email을 가져온다.") + void getEmail() { + //given + String email = "email@email.com"; + Map info = Map.of("email", email); + + //when + OauthUserInfo githubUserInfo = GithubUserInfo.from(info); + + //then + assertThat(githubUserInfo.getEmail()).isEqualTo(email); + } + + @Test + @DisplayName("map으로 받아온 정보에 email이 존재하지 않으면 오류가 발생한다.") + void getEmailException() { + //given + Map info = Map.of("name", "name"); + + //when + OauthUserInfo githubUserInfo = GithubUserInfo.from(info); + + //then + assertThatThrownBy(githubUserInfo::getEmail) + .isInstanceOf(NoPublicEmailOnGithubException.class); + } +} diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/dto/MemberUpdateRequestTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/dto/MemberUpdateRequestTest.java new file mode 100644 index 000000000..ddf59161a --- /dev/null +++ b/backend/src/test/java/com/woowacourse/zzimkkong/dto/MemberUpdateRequestTest.java @@ -0,0 +1,22 @@ +package com.woowacourse.zzimkkong.dto; + +import com.woowacourse.zzimkkong.dto.member.MemberSaveRequest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; + +import static com.woowacourse.zzimkkong.dto.ValidatorMessage.EMPTY_MESSAGE; +import static org.assertj.core.api.Assertions.assertThat; + +class MemberUpdateRequestTest extends RequestTest { + @ParameterizedTest + @NullSource + @DisplayName("회원 정보 수정 조직명에 빈 문자열이 들어오면 처리한다.") + void blankOrganization(String organization) { + MemberSaveRequest memberSaveRequest = new MemberSaveRequest("email@email.com", "password", organization); + + assertThat(getConstraintViolations(memberSaveRequest).stream() + .anyMatch(violation -> violation.getMessage().equals(EMPTY_MESSAGE))) + .isTrue(); + } +} diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/dto/OauthMemberSaveRequestTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/dto/OauthMemberSaveRequestTest.java new file mode 100644 index 000000000..bf4f25cde --- /dev/null +++ b/backend/src/test/java/com/woowacourse/zzimkkong/dto/OauthMemberSaveRequestTest.java @@ -0,0 +1,68 @@ +package com.woowacourse.zzimkkong.dto; + +import com.woowacourse.zzimkkong.dto.member.oauth.OauthMemberSaveRequest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.NullSource; + +import static com.woowacourse.zzimkkong.dto.ValidatorMessage.*; +import static org.assertj.core.api.Assertions.assertThat; + +class OauthMemberSaveRequestTest extends RequestTest { + @ParameterizedTest + @NullAndEmptySource + @DisplayName("oauth 회원가입 이메일에 빈 문자열이 들어오면 처리한다.") + void blankEmail(String email) { + OauthMemberSaveRequest oauthMemberSaveRequest = new OauthMemberSaveRequest(email, "ORGANIZTION", "GOOGLE"); + + assertThat(getConstraintViolations(oauthMemberSaveRequest).stream() + .anyMatch(violation -> violation.getMessage().equals(EMPTY_MESSAGE))) + .isTrue(); + } + + @ParameterizedTest + @CsvSource(value = {"email:true", "email@email:false", "email@email.com:false"}, delimiter = ':') + @DisplayName("oauth 회원가입 이메일에 옳지 않은 이메일 형식의 문자열이 들어오면 처리한다.") + void invalidEmail(String email, boolean flag) { + OauthMemberSaveRequest oauthMemberSaveRequest = new OauthMemberSaveRequest(email, "ORGANIZTION", "GOOGLE"); + + assertThat(getConstraintViolations(oauthMemberSaveRequest).stream() + .anyMatch(violation -> violation.getMessage().equals(EMAIL_MESSAGE))) + .isEqualTo(flag); + } + + @ParameterizedTest + @NullSource + @DisplayName("회원가입 조직명에 빈 문자열이 들어오면 처리한다.") + void blankOrganization(String organization) { + OauthMemberSaveRequest oauthMemberSaveRequest = new OauthMemberSaveRequest("email@email.com", organization, "GOOGLE"); + + assertThat(getConstraintViolations(oauthMemberSaveRequest).stream() + .anyMatch(violation -> violation.getMessage().equals(EMPTY_MESSAGE))) + .isTrue(); + } + + @ParameterizedTest + @CsvSource(value = {"hihellomorethantwenty:true", "한글조직:false", "hihello:false", "안 녕 하 세 요:false", "ㄱㄴ 힣 ㄷㄹ:false"}, delimiter = ':') + @DisplayName("회원가입 조직명에 옳지 않은 형식의 문자열이 들어오면 처리한다.") + void invalidOrganization(String organization, boolean flag) { + OauthMemberSaveRequest oauthMemberSaveRequest = new OauthMemberSaveRequest("email@email.com", organization, "GOOGLE"); + + assertThat(getConstraintViolations(oauthMemberSaveRequest).stream() + .anyMatch(violation -> violation.getMessage().equals(ORGANIZATION_MESSAGE))) + .isEqualTo(flag); + } + + @ParameterizedTest + @NullSource + @DisplayName("회원가입한 oauth 제공사에 빈 문자열이 들어오면 처리한다.") + void blankOauthProvider(String oauthProvider) { + OauthMemberSaveRequest oauthMemberSaveRequest = new OauthMemberSaveRequest("email@email.com", "organization", oauthProvider); + + assertThat(getConstraintViolations(oauthMemberSaveRequest).stream() + .anyMatch(violation -> violation.getMessage().equals(EMPTY_MESSAGE))) + .isTrue(); + } +} diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/dto/SettingsRequestTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/dto/SettingsRequestTest.java index eb02290ee..17e4563ac 100644 --- a/backend/src/test/java/com/woowacourse/zzimkkong/dto/SettingsRequestTest.java +++ b/backend/src/test/java/com/woowacourse/zzimkkong/dto/SettingsRequestTest.java @@ -35,7 +35,7 @@ void invalidTimeUnit(int timeUnit) { @ParameterizedTest @NullSource - @ValueSource(ints = {5, 10, 30, 60, 120}) + @ValueSource(ints = {5, 10, 30, 60}) @DisplayName("공간의 예약 설정에 단위 시간이 올바르게 들어온다.") void validTimeUnit(Integer timeUnit) { SettingsRequest settingsRequest = new SettingsRequest( diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/infrastructure/oauth/GithubRequesterTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/infrastructure/oauth/GithubRequesterTest.java new file mode 100644 index 000000000..e68ca00a7 --- /dev/null +++ b/backend/src/test/java/com/woowacourse/zzimkkong/infrastructure/oauth/GithubRequesterTest.java @@ -0,0 +1,126 @@ +package com.woowacourse.zzimkkong.infrastructure.oauth; + +import com.woowacourse.zzimkkong.Constants; +import com.woowacourse.zzimkkong.domain.oauth.OauthUserInfo; +import com.woowacourse.zzimkkong.exception.infrastructure.oauth.ErrorResponseToGetAccessTokenException; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; + +import java.io.IOException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@ActiveProfiles("test") +class GithubRequesterTest { + + private static final String ACCESS_TOKEN_RESPONSE_EXAMPLE = "{\n" + + " \"access_token\": \"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\",\n" + + " \"token_type\": \"bearer\",\n" + + " \"scope\": \"user:email\"\n" + + "}"; + + private static final String USER_INFO_RESPONSE_EXAMPLE = "{\n" + + " \"login\": \"pobi\",\n" + + " \"id\": 1,\n" + + " \"node_id\": \"MDQ6VXNlcjQ5MzQ2Njc3\",\n" + + " \"avatar_url\": \"https://avatars.githubusercontent.com/u/49346677?v=4\",\n" + + " \"gravatar_id\": \"\",\n" + + " \"url\": \"https://api.github.com/users/tributetothemoon\",\n" + + " \"html_url\": \"https://github.com/tributetothemoon\",\n" + + " \"followers_url\": \"https://api.github.com/users/tributetothemoon/followers\",\n" + + " \"following_url\": \"https://api.github.com/users/tributetothemoon/following{/other_user}\",\n" + + " \"gists_url\": \"https://api.github.com/users/tributetothemoon/gists{/gist_id}\",\n" + + " \"starred_url\": \"https://api.github.com/users/tributetothemoon/starred{/owner}{/repo}\",\n" + + " \"subscriptions_url\": \"https://api.github.com/users/tributetothemoon/subscriptions\",\n" + + " \"organizations_url\": \"https://api.github.com/users/tributetothemoon/orgs\",\n" + + " \"repos_url\": \"https://api.github.com/users/tributetothemoon/repos\",\n" + + " \"events_url\": \"https://api.github.com/users/tributetothemoon/events{/privacy}\",\n" + + " \"received_events_url\": \"https://api.github.com/users/tributetothemoon/received_events\",\n" + + " \"type\": \"User\",\n" + + " \"site_admin\": false,\n" + + " \"name\": \"Jaesung Park\",\n" + + " \"company\": \"@woowacourse\",\n" + + " \"blog\": \"woowacourse.github.io\",\n" + + " \"location\": \"Seoul, Korea\",\n" + + " \"email\": \"pobi@email.com\",\n" + + " \"hireable\": null,\n" + + " \"bio\": null,\n" + + " \"twitter_username\": null,\n" + + " \"public_repos\": 27,\n" + + " \"public_gists\": 0,\n" + + " \"followers\": 300000,\n" + + " \"following\": 1,\n" + + " \"created_at\": \"2019-04-06T16:39:37Z\",\n" + + " \"updated_at\": \"2021-09-05T07:23:40Z\"\n" + + "}"; + + @Test + @DisplayName("서버에 요청을 보내 코드로부터 유저의 정보를 가져온다.") + void getUserInfoByCode() { + try (MockWebServer mockGithubServer = new MockWebServer()) { + // given + mockGithubServer.start(); + + setUpGetTokenResponse(mockGithubServer, ACCESS_TOKEN_RESPONSE_EXAMPLE); + setUpGetTokenResponse(mockGithubServer, USER_INFO_RESPONSE_EXAMPLE); + + GithubRequester githubRequester = new GithubRequester( + "clientId", + "secretId", + String.format("http://%s:%s", mockGithubServer.getHostName(), mockGithubServer.getPort()), + String.format("http://%s:%s", mockGithubServer.getHostName(), mockGithubServer.getPort()) + ); + + // when + OauthUserInfo code = githubRequester.getUserInfoByCode("code"); + String email = code.getEmail(); + + // then + assertThat(email).isEqualTo(Constants.EMAIL); + mockGithubServer.shutdown(); + } catch (IOException ignored) { + } + } + + @Test + @DisplayName("오류가 발생하면 ErrorResponseToGetGithubAccessTokenException을 응답한다.") + void getTokenException() { + String getTokenErrorResponse = "{\n" + + " \"error\": \"bad_verification_code\",\n" + + " \"error_description\": \"The code passed is incorrect or expired.\",\n" + + " \"error_uri\": \"/apps/managing-oauth-apps/troubleshooting-oauth-app-access-token-request-errors/#bad-verification-code\"\n" + + "}"; + + try (MockWebServer mockGithubServer = new MockWebServer()) { + // given + mockGithubServer.start(); + + setUpGetTokenResponse(mockGithubServer, getTokenErrorResponse); + setUpGetTokenResponse(mockGithubServer, USER_INFO_RESPONSE_EXAMPLE); + + GithubRequester githubRequester = new GithubRequester( + "clientId", + "secretId", + String.format("http://%s:%s", mockGithubServer.getHostName(), mockGithubServer.getPort()), + String.format("http://%s:%s", mockGithubServer.getHostName(), mockGithubServer.getPort()) + ); + + // when, then + assertThatThrownBy(() -> githubRequester.getUserInfoByCode("code")) + .isInstanceOf(ErrorResponseToGetAccessTokenException.class); + } catch (IOException ignored) { + } + } + + private void setUpGetTokenResponse(MockWebServer mockGithubServer, String responseExample) { + mockGithubServer.enqueue(new MockResponse() + .setBody(responseExample) + .addHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)); + } +} diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/infrastructure/oauth/GoogleRequesterTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/infrastructure/oauth/GoogleRequesterTest.java new file mode 100644 index 000000000..e136a7320 --- /dev/null +++ b/backend/src/test/java/com/woowacourse/zzimkkong/infrastructure/oauth/GoogleRequesterTest.java @@ -0,0 +1,134 @@ +package com.woowacourse.zzimkkong.infrastructure.oauth; + +import com.woowacourse.zzimkkong.domain.OauthProvider; +import com.woowacourse.zzimkkong.domain.oauth.OauthUserInfo; +import com.woowacourse.zzimkkong.exception.infrastructure.oauth.ErrorResponseToGetAccessTokenException; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; + +import java.io.IOException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@ActiveProfiles("test") +class GoogleRequesterTest { + public static final String SALLY_EMAIL = "dusdn1702@gmail.com"; + private static final String GOOGLE_TOKEN_RESPONSE = "{\n" + + " \"access_token\": \"ACCESS_TOKEN_AT_HERE\",\n" + + " \"expires_in\": \"3599\",\n" + + " \"refresh_token\": \"REFRESH_TOKEN_AT_HERE\",\n" + + " \"scope\": \"https://www.googleapis.com/auth/drive.metadata.readonly https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/indexing openid https://www.googleapis.com/auth/userinfo.email\",\n" + + " \"token_type\": \"bearer\",\n" + + " \"id_token\": \"ID_TOKEN_AT_HERE\"\n" + + "}"; + + private static final String USER_INFO_RESPONSE_EXAMPLE = "{\n" + + " \"id\": \"107677594285931275665\",\n" + + " \"email\": \"" + SALLY_EMAIL + "\",\n" + + " \"verified_email\": true,\n" + + " \"name\": \"Yeonwoo Cho\",\n" + + " \"given_name\": \"Yeonwoo\",\n" + + " \"family_name\": \"Cho\",\n" + + " \"picture\": \"https://lh3.googleusercontent.com/a/AATXAJyyWzN0hxXPXy_hoPNk7ww9Kuu990o-ImGrdPe9=s96-c\",\n" + + " \"locale\": \"ko\"\n" + + "}"; + + @Test + @DisplayName("서버에 요청을 보내 코드로부터 유저의 정보를 가져온다.") + void getUserInfoByCode() { + try (MockWebServer mockGoogleServer = new MockWebServer()) { + // given + mockGoogleServer.start(); + + setUpResponse(mockGoogleServer); + + GoogleRequester googleRequester = new GoogleRequester( + "clientId", + "secretId", + "redirectUri", + String.format("http://%s:%s", mockGoogleServer.getHostName(), mockGoogleServer.getPort()), + String.format("http://%s:%s", mockGoogleServer.getHostName(), mockGoogleServer.getPort()) + ); + + // when + OauthUserInfo code = googleRequester.getUserInfoByCode("code"); + String email = code.getEmail(); + + //then + assertThat(email).isEqualTo(SALLY_EMAIL); + mockGoogleServer.shutdown(); + } catch (IOException ignored) { + } + } + + @Test + @DisplayName("들어온 oauth 제공자가 자신이면 true를 반환한다.") + void supports() { + GoogleRequester googleRequester = new GoogleRequester( + "clientId", + "secretId", + "redirectUri", + "baseLoginUri", + "baseUserUri" + ); + + assertThat(googleRequester.supports(OauthProvider.GOOGLE)).isTrue(); + assertThat(googleRequester.supports(OauthProvider.GITHUB)).isFalse(); + } + + private void setUpResponse(MockWebServer mockGoogleServer) { + mockGoogleServer.enqueue(new MockResponse() + .setBody(GOOGLE_TOKEN_RESPONSE) + .addHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)); + + mockGoogleServer.enqueue(new MockResponse() + .setBody(USER_INFO_RESPONSE_EXAMPLE) + .addHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)); + } + + @Value("${google.uri.redirect}") + private String redirectUri; + + @Test + @DisplayName("오류가 발생하면 ErrorResponseToGetAccessTokenException을 응답한다.") + void getTokenException() { + String getTokenErrorResponse = "{\n" + + " \"error\": \"redirect_uri_mismatch\",\n" + + " \"error_description\": \"Bad Request\"\n" + + "}"; + + try (MockWebServer mockGoogleServer = new MockWebServer()) { + // given + mockGoogleServer.start(); + + setUpGetTokenResponse(mockGoogleServer, getTokenErrorResponse); + setUpGetTokenResponse(mockGoogleServer, USER_INFO_RESPONSE_EXAMPLE); + + GoogleRequester googleRequester = new GoogleRequester( + "clientId", + "secretId", + this.redirectUri, + String.format("http://%s:%s", mockGoogleServer.getHostName(), mockGoogleServer.getPort()), + String.format("http://%s:%s", mockGoogleServer.getHostName(), mockGoogleServer.getPort()) + ); + + // when, then + assertThatThrownBy(() -> googleRequester.getUserInfoByCode("code")) + .isInstanceOf(ErrorResponseToGetAccessTokenException.class); + } catch (IOException ignored) { + } + } + + private void setUpGetTokenResponse(MockWebServer mockGoogleServer, String responseExample) { + mockGoogleServer.enqueue(new MockResponse() + .setBody(responseExample) + .addHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)); + } +} diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/infrastructure/oauth/OauthHandlerTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/infrastructure/oauth/OauthHandlerTest.java new file mode 100644 index 000000000..1e8fb566d --- /dev/null +++ b/backend/src/test/java/com/woowacourse/zzimkkong/infrastructure/oauth/OauthHandlerTest.java @@ -0,0 +1,67 @@ +package com.woowacourse.zzimkkong.infrastructure.oauth; + +import com.woowacourse.zzimkkong.domain.OauthProvider; +import com.woowacourse.zzimkkong.domain.oauth.GithubUserInfo; +import com.woowacourse.zzimkkong.domain.oauth.GoogleUserInfo; +import com.woowacourse.zzimkkong.domain.oauth.OauthUserInfo; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.ActiveProfiles; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; + +@SpringBootTest +@ActiveProfiles("test") +class OauthHandlerTest { + public static final String SALLY_EMAIL = "dusdn1702@gmail.com"; + + @Autowired + private OauthHandler oauthHandler; + + @MockBean + private GoogleRequester googleRequester; + + @MockBean + private GithubRequester githubRequester; + + @ParameterizedTest + @DisplayName("Oauth 제공사에 따라 적당한 OauthRequester를 찾아 code로부터 유저 정보를 가져온다.") + @EnumSource(OauthProvider.class) + void getUserInfoFromCodeWithGithub(OauthProvider oauthProvider) { + //given + given(githubRequester.supports(OauthProvider.GITHUB)) + .willReturn(true); + mockingGithubGetUserInfo(SALLY_EMAIL); + + given(googleRequester.supports(OauthProvider.GOOGLE)) + .willReturn(true); + mockingGoogleGetUserInfo(SALLY_EMAIL); + + //when + OauthUserInfo oauthUserInfo = oauthHandler.getUserInfoFromCode(oauthProvider, "code"); + String email = oauthUserInfo.getEmail(); + + //then + assertThat(email).isEqualTo(SALLY_EMAIL); + } + + private void mockingGithubGetUserInfo(String email) { + given(githubRequester.getUserInfoByCode(anyString())) + .willReturn(GithubUserInfo.from(Map.of("email", email))); + } + + private void mockingGoogleGetUserInfo(String email) { + given(googleRequester.getUserInfoByCode(anyString())) + .willReturn(GoogleUserInfo.from( + Map.of("id", "12", + "email", email))); + } +} diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/repository/MemberRepositoryTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/repository/MemberRepositoryTest.java index 92fa28488..92c2eb7e2 100644 --- a/backend/src/test/java/com/woowacourse/zzimkkong/repository/MemberRepositoryTest.java +++ b/backend/src/test/java/com/woowacourse/zzimkkong/repository/MemberRepositoryTest.java @@ -1,10 +1,12 @@ package com.woowacourse.zzimkkong.repository; -import com.woowacourse.zzimkkong.domain.Member; +import com.woowacourse.zzimkkong.domain.*; import com.woowacourse.zzimkkong.exception.member.NoSuchMemberException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.springframework.dao.DataAccessException; import static com.woowacourse.zzimkkong.Constants.*; diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/repository/ReservationRepositoryImplTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/repository/ReservationRepositoryImplTest.java new file mode 100644 index 000000000..8d2ad8a53 --- /dev/null +++ b/backend/src/test/java/com/woowacourse/zzimkkong/repository/ReservationRepositoryImplTest.java @@ -0,0 +1,79 @@ +package com.woowacourse.zzimkkong.repository; + +import com.woowacourse.zzimkkong.domain.*; +import org.assertj.core.api.AssertionsForClassTypes; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +import static com.woowacourse.zzimkkong.Constants.*; + +class ReservationRepositoryImplTest extends RepositoryTest { + @ParameterizedTest + @CsvSource({"true", "false"}) + @DisplayName("멤버를 이용해 오늘 이후의 예약이 존재하는지 확인할 수 있다.") + void existsReservationsByMember(boolean isReservationExists) { + // given + Member sakjung = new Member(NEW_EMAIL, PW, ORGANIZATION); + Member savedMember = members.save(sakjung); + + Map luther = new Map(LUTHER_NAME, MAP_DRAWING_DATA, MAP_IMAGE_URL, savedMember); + maps.save(luther); + + Setting beSetting = Setting.builder() + .availableStartTime(BE_AVAILABLE_START_TIME) + .availableEndTime(BE_AVAILABLE_END_TIME) + .reservationTimeUnit(BE_RESERVATION_TIME_UNIT) + .reservationMinimumTimeUnit(BE_RESERVATION_MINIMUM_TIME_UNIT) + .reservationMaximumTimeUnit(BE_RESERVATION_MAXIMUM_TIME_UNIT) + .reservationEnable(BE_RESERVATION_ENABLE) + .enabledDayOfWeek(BE_ENABLED_DAY_OF_WEEK) + .build(); + + Space be = Space.builder() + .name(BE_NAME) + .color(BE_COLOR) + .description(BE_DESCRIPTION) + .area(SPACE_DRAWING) + .setting(beSetting) + .map(luther) + .build(); + + spaces.save(be); + + Reservation beAmZeroOneYesterday = Reservation.builder() + .date(LocalDate.now().minusDays(1)) + .startTime(LocalDateTime.now().minusDays(1)) + .endTime(LocalDateTime.now().minusDays(1).plusHours(1)) + .description(BE_AM_TEN_ELEVEN_DESCRIPTION) + .userName(BE_AM_TEN_ELEVEN_USERNAME) + .password(BE_AM_TEN_ELEVEN_PW) + .space(be) + .build(); + + reservations.save(beAmZeroOneYesterday); + + if (isReservationExists) { + Reservation beAmZeroOne = Reservation.builder() + .date(BE_AM_TEN_ELEVEN_START_TIME.toLocalDate()) + .startTime(BE_AM_TEN_ELEVEN_START_TIME) + .endTime(BE_AM_TEN_ELEVEN_END_TIME) + .description(BE_AM_TEN_ELEVEN_DESCRIPTION) + .userName(BE_AM_TEN_ELEVEN_USERNAME) + .password(BE_AM_TEN_ELEVEN_PW) + .space(be) + .build(); + + reservations.save(beAmZeroOne); + } + + // when + Boolean hasAnyReservations = reservations.existsReservationsByMemberFromToday(savedMember); + + // then + AssertionsForClassTypes.assertThat(hasAnyReservations).isEqualTo(isReservationExists); + } +} diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/repository/ReservationRepositoryTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/repository/ReservationRepositoryTest.java index b0b84b668..b7c5ae7d9 100644 --- a/backend/src/test/java/com/woowacourse/zzimkkong/repository/ReservationRepositoryTest.java +++ b/backend/src/test/java/com/woowacourse/zzimkkong/repository/ReservationRepositoryTest.java @@ -1,12 +1,14 @@ package com.woowacourse.zzimkkong.repository; import com.woowacourse.zzimkkong.domain.*; +import org.assertj.core.api.AssertionsForClassTypes; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; +import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; @@ -23,9 +25,11 @@ class ReservationRepositoryTest extends RepositoryTest { private Reservation beNextDayAmSixTwelve; private Reservation fe1ZeroOne; + private Member pobi; + @BeforeEach void setUp() { - Member pobi = new Member(EMAIL, PW, ORGANIZATION); + pobi = new Member(EMAIL, PW, ORGANIZATION); Map luther = new Map(LUTHER_NAME, MAP_DRAWING_DATA, MAP_IMAGE_URL, pobi); Setting beSetting = Setting.builder() @@ -72,6 +76,7 @@ void setUp() { spaces.save(fe); beAmZeroOne = Reservation.builder() + .date(BE_AM_TEN_ELEVEN_START_TIME.toLocalDate()) .startTime(BE_AM_TEN_ELEVEN_START_TIME) .endTime(BE_AM_TEN_ELEVEN_END_TIME) .description(BE_AM_TEN_ELEVEN_DESCRIPTION) @@ -81,6 +86,7 @@ void setUp() { .build(); bePmOneTwo = Reservation.builder() + .date(BE_PM_ONE_TWO_START_TIME.toLocalDate()) .startTime(BE_PM_ONE_TWO_START_TIME) .endTime(BE_PM_ONE_TWO_END_TIME) .description(BE_PM_ONE_TWO_DESCRIPTION) @@ -90,6 +96,7 @@ void setUp() { .build(); beNextDayAmSixTwelve = Reservation.builder() + .date(BE_NEXT_DAY_PM_FOUR_TO_SIX_START_TIME.toLocalDate()) .startTime(BE_NEXT_DAY_PM_FOUR_TO_SIX_START_TIME) .endTime(BE_NEXT_DAY_PM_FOUR_TO_SIX_END_TIME) .description(BE_NEXT_DAY_PM_FOUR_TO_SIX_DESCRIPTION) @@ -99,6 +106,7 @@ void setUp() { .build(); fe1ZeroOne = Reservation.builder() + .date(FE1_AM_TEN_ELEVEN_START_TIME.toLocalDate()) .startTime(FE1_AM_TEN_ELEVEN_START_TIME) .endTime(FE1_AM_TEN_ELEVEN_END_TIME) .description(FE1_AM_TEN_ELEVEN_DESCRIPTION) @@ -118,6 +126,7 @@ void setUp() { void save() { //given Reservation be_two_three = Reservation.builder() + .date(THE_DAY_AFTER_TOMORROW) .startTime(THE_DAY_AFTER_TOMORROW.atTime(2, 0)) .endTime(THE_DAY_AFTER_TOMORROW.atTime(3, 0)) .description("찜꽁 4차 회의") @@ -136,12 +145,11 @@ void save() { @Test @DisplayName("map id, space id, 특정 시간이 주어질 때, 해당 spaceId와 해당 시간에 속하는 예약들만 찾아온다") - void findAllBySpaceIdAndStartTimeIsBetweenAndEndTimeIsBetween() { + void findAllBySpaceIdInAndDate() { // given, when List foundReservations = getReservations( List.of(be.getId(), fe.getId()), - THE_DAY_AFTER_TOMORROW.atTime(0, 0), - THE_DAY_AFTER_TOMORROW.atTime(14, 0)); + THE_DAY_AFTER_TOMORROW); // then assertThat(foundReservations).usingRecursiveComparison() @@ -149,13 +157,12 @@ void findAllBySpaceIdAndStartTimeIsBetweenAndEndTimeIsBetween() { } @Test - @DisplayName("특정 시간에 부합하는 예약이 없으면 빈 리스트를 반환한다") - void findAllBySpaceIdAndStartTimeIsBetweenAndEndTimeIsBetween_noMatchingTime() { + @DisplayName("특정 날짜에 부합하는 예약이 없으면 빈 리스트를 반환한다") + void findAllBySpaceIdInAndDate_noMatchingTime() { // given, when List foundReservations = getReservations( List.of(be.getId()), - THE_DAY_AFTER_TOMORROW.atTime(15, 0), - THE_DAY_AFTER_TOMORROW.atTime(18, 0)); + THE_DAY_AFTER_TOMORROW.plusDays(2)); // then assertThat(foundReservations).isEmpty(); @@ -163,12 +170,11 @@ void findAllBySpaceIdAndStartTimeIsBetweenAndEndTimeIsBetween_noMatchingTime() { @Test @DisplayName("특정 공간에 부합하는 예약이 없으면 빈 리스트를 반환한다") - void findAllBySpaceIdAndStartTimeIsBetweenAndEndTimeIsBetween_noMatchingReservation() { + void findAllBySpaceIdInAndDate_noMatchingReservation() { // given, when List foundReservations = getReservations( List.of(fe.getId()), - THE_DAY_AFTER_TOMORROW.atTime(13, 0), - THE_DAY_AFTER_TOMORROW.atTime(14, 0)); + THE_DAY_AFTER_TOMORROW.plusDays(1)); // then assertThat(foundReservations).isEmpty(); @@ -176,12 +182,11 @@ void findAllBySpaceIdAndStartTimeIsBetweenAndEndTimeIsBetween_noMatchingReservat @Test @DisplayName("map id와 특정 날짜가 주어질 때, 해당 날짜에 속하는 해당 map의 모든 space들의 예약들을 찾아온다") - void findAllBySpaceIdAndStartTimeIsBetweenAndEndTimeIsBetween_allSpaces() { + void findAllBySpaceIdInAndDate_allSpaces() { // given, when List foundReservations = getReservations( List.of(be.getId(), fe.getId()), - THE_DAY_AFTER_TOMORROW.atTime(0, 0), - THE_DAY_AFTER_TOMORROW.atTime(0, 0).plusDays(1)); + THE_DAY_AFTER_TOMORROW); // then assertThat(foundReservations).containsExactlyInAnyOrderElementsOf(List.of(beAmZeroOne, bePmOneTwo, fe1ZeroOne)); @@ -207,7 +212,7 @@ void existsBySpace() { @ParameterizedTest @CsvSource(value = {"0:false", "30:true"}, delimiter = ':') @DisplayName("특정 시간 이후의 예약이 존재하는지 확인한다.") - void existsAllStartTimeAfter(int minusMinute, boolean expected) { + void existsBySpaceIdAndDateGreaterThanEqual(int minusMinute, boolean expected) { //given, when Boolean actual = reservations.existsBySpaceIdAndEndTimeAfter(be.getId(), BE_NEXT_DAY_PM_FOUR_TO_SIX_END_TIME.minusMinutes(minusMinute)); @@ -215,13 +220,7 @@ void existsAllStartTimeAfter(int minusMinute, boolean expected) { assertThat(actual).isEqualTo(expected); } - private List getReservations(List spaceIds, LocalDateTime minimumDateTime, LocalDateTime maximumDateTime) { - return reservations.findAllBySpaceIdInAndStartTimeIsBetweenAndEndTimeIsBetween( - spaceIds, - minimumDateTime, - maximumDateTime, - minimumDateTime, - maximumDateTime - ); + private List getReservations(List spaceIds, LocalDate date) { + return reservations.findAllBySpaceIdInAndDate(spaceIds, date); } } diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/service/AuthServiceTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/service/AuthServiceTest.java index 473a554e9..9528775e7 100644 --- a/backend/src/test/java/com/woowacourse/zzimkkong/service/AuthServiceTest.java +++ b/backend/src/test/java/com/woowacourse/zzimkkong/service/AuthServiceTest.java @@ -1,22 +1,38 @@ package com.woowacourse.zzimkkong.service; import com.woowacourse.zzimkkong.domain.Member; +import com.woowacourse.zzimkkong.domain.OauthProvider; +import com.woowacourse.zzimkkong.domain.oauth.OauthUserInfo; import com.woowacourse.zzimkkong.dto.member.LoginRequest; import com.woowacourse.zzimkkong.dto.member.TokenResponse; +import com.woowacourse.zzimkkong.exception.authorization.OauthProviderMismatchException; import com.woowacourse.zzimkkong.exception.member.NoSuchMemberException; +import com.woowacourse.zzimkkong.exception.member.NoSuchOAuthMemberException; import com.woowacourse.zzimkkong.exception.member.PasswordMismatchException; +import com.woowacourse.zzimkkong.infrastructure.JwtUtils; +import com.woowacourse.zzimkkong.infrastructure.oauth.OauthHandler; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.security.core.parameters.P; +import java.util.Arrays; import java.util.Optional; +import java.util.stream.Stream; import static com.woowacourse.zzimkkong.Constants.*; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; class AuthServiceTest extends ServiceTest { private Member pobi; @@ -26,9 +42,15 @@ void setUp() { pobi = new Member(EMAIL, passwordEncoder.encode(PW), ORGANIZATION); } + @MockBean + private OauthHandler oauthHandler; + @Autowired private AuthService authService; + @Autowired + private JwtUtils jwtUtils; + @Test @DisplayName("회원 로그인 요청이 옳다면 토큰을 발급한다.") void login() { @@ -74,4 +96,76 @@ void loginMismatchException() { assertThatThrownBy(() -> authService.login(loginRequest)) .isInstanceOf(PasswordMismatchException.class); } + + @ParameterizedTest + @EnumSource(OauthProvider.class) + @DisplayName("Oauth 인증 코드를 통해 토큰을 발급한다.") + void loginByOauth(OauthProvider oauthProvider) { + // given + String mockCode = "Mock Code from OauthProvider"; + + OauthUserInfo mockOauthUserInfo = mock(OauthUserInfo.class); + given(oauthHandler.getUserInfoFromCode(any(OauthProvider.class), anyString())) + .willReturn(mockOauthUserInfo); + given(mockOauthUserInfo.getEmail()) + .willReturn(EMAIL); + given(members.findByEmail(EMAIL)) + .willReturn(Optional.of(new Member(EMAIL, ORGANIZATION, oauthProvider))); + + // when + TokenResponse tokenResponse = authService.loginByOauth(oauthProvider, mockCode); + + // then + String accessToken = tokenResponse.getAccessToken(); + assertThat(accessToken).isNotNull(); + jwtUtils.validateToken(accessToken); + } + + @ParameterizedTest + @EnumSource(OauthProvider.class) + @DisplayName("존재하지 않는 이메일로 oauth 로그인 시 오류가 발생한다.") + void loginByOauthInvalidEmailException(OauthProvider oauthProvider) { + // given + String mockCode = "Mock Code from OauthProvider"; + + OauthUserInfo mockOauthUserInfo = mock(OauthUserInfo.class); + given(oauthHandler.getUserInfoFromCode(any(OauthProvider.class), anyString())) + .willReturn(mockOauthUserInfo); + given(mockOauthUserInfo.getEmail()) + .willReturn(EMAIL); + given(members.findByEmail(EMAIL)) + .willReturn(Optional.empty()); + + // when, then + assertThatThrownBy(() -> authService.loginByOauth(oauthProvider, mockCode)) + .isInstanceOf(NoSuchOAuthMemberException.class); + } + + static Stream loginByOauthInvalidProviderException() { + return Stream.of( + Arguments.of(OauthProvider.GITHUB, OauthProvider.GOOGLE), + Arguments.of(OauthProvider.GOOGLE, OauthProvider.GITHUB), + Arguments.of(OauthProvider.GOOGLE, null) + ); + } + + @ParameterizedTest + @MethodSource + @DisplayName("회원가입한 provider와 다른 provider로 같은 이메일 oauth 로그인 시 오류가 발생한다.") + void loginByOauthInvalidProviderException(OauthProvider oauthProvider, OauthProvider actualOauthProvider) { + // given + String mockCode = "Mock Code from OauthProvider"; + + OauthUserInfo mockOauthUserInfo = mock(OauthUserInfo.class); + given(oauthHandler.getUserInfoFromCode(any(OauthProvider.class), anyString())) + .willReturn(mockOauthUserInfo); + given(mockOauthUserInfo.getEmail()) + .willReturn(EMAIL); + given(members.findByEmail(EMAIL)) + .willReturn(Optional.of(new Member(EMAIL, ORGANIZATION, actualOauthProvider))); + + // when, then + assertThatThrownBy(() -> authService.loginByOauth(oauthProvider, mockCode)) + .isInstanceOf(OauthProviderMismatchException.class); + } } diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/service/GuestReservationServiceTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/service/GuestReservationServiceTest.java index ecde41521..429204ea2 100644 --- a/backend/src/test/java/com/woowacourse/zzimkkong/service/GuestReservationServiceTest.java +++ b/backend/src/test/java/com/woowacourse/zzimkkong/service/GuestReservationServiceTest.java @@ -14,6 +14,7 @@ import org.junit.jupiter.params.provider.ValueSource; import org.springframework.beans.factory.annotation.Autowired; +import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; import java.util.Arrays; @@ -333,12 +334,9 @@ void saveAvailabilityException(int startMinute, int endMinute) { //given, when given(maps.findById(anyLong())) .willReturn(Optional.of(luther)); - given(reservations.findAllBySpaceIdInAndStartTimeIsBetweenAndEndTimeIsBetween( - any(), - any(LocalDateTime.class), - any(LocalDateTime.class), - any(LocalDateTime.class), - any(LocalDateTime.class))) + given(reservations.findAllBySpaceIdInAndDate( + anyList(), + any(LocalDate.class))) .willReturn(List.of(makeReservation( reservationCreateUpdateWithPasswordRequest.getStartDateTime().minusMinutes(startMinute), reservationCreateUpdateWithPasswordRequest.getEndDateTime().plusMinutes(endMinute), @@ -365,7 +363,7 @@ void saveReservationUnable() { .availableEndTime(LocalTime.of(18, 0)) .reservationTimeUnit(10) .reservationMinimumTimeUnit(10) - .reservationMaximumTimeUnit(1440) + .reservationMaximumTimeUnit(120) .reservationEnable(false) .enabledDayOfWeek(null) .build(); @@ -405,7 +403,7 @@ void saveIllegalDayOfWeek() { .availableEndTime(LocalTime.of(18, 0)) .reservationTimeUnit(10) .reservationMinimumTimeUnit(10) - .reservationMaximumTimeUnit(1440) + .reservationMaximumTimeUnit(120) .reservationEnable(true) .enabledDayOfWeek(THE_DAY_AFTER_TOMORROW.plusDays(1L).getDayOfWeek().name()) .build(); @@ -443,12 +441,9 @@ void saveSameThresholdTime(int duration) { //given, when given(maps.findById(anyLong())) .willReturn(Optional.of(luther)); - given(reservations.findAllBySpaceIdInAndStartTimeIsBetweenAndEndTimeIsBetween( + given(reservations.findAllBySpaceIdInAndDate( anyList(), - any(LocalDateTime.class), - any(LocalDateTime.class), - any(LocalDateTime.class), - any(LocalDateTime.class))) + any(LocalDate.class))) .willReturn(List.of( makeReservation( reservationCreateUpdateWithPasswordRequest.getStartDateTime().minusMinutes(duration), @@ -474,9 +469,9 @@ void saveSameThresholdTime(int duration) { } @ParameterizedTest - @ValueSource(ints = {1, 2, 3, 4, 5, 9, 15, 29}) + @CsvSource({"1,61","10,55","5,65","20,89"}) @DisplayName("예약 생성/수정 요청 시, space setting의 reservationTimeUnit이 일치하지 않으면 예외가 발생한다.") - void saveReservationTimeUnitException(int minute) { + void saveReservationTimeUnitException(int additionalStartMinute, int additionalEndMinute) { //given given(maps.findById(anyLong())) .willReturn(Optional.of(luther)); @@ -486,8 +481,8 @@ void saveReservationTimeUnitException(int minute) { //when ReservationCreateUpdateWithPasswordRequest reservationCreateUpdateWithPasswordRequest = new ReservationCreateUpdateWithPasswordRequest( - theDayAfterTomorrowTen.plusMinutes(minute), - theDayAfterTomorrowTen.plusMinutes(minute).plusMinutes(60), + theDayAfterTomorrowTen.plusMinutes(additionalStartMinute), + theDayAfterTomorrowTen.plusMinutes(additionalEndMinute), RESERVATION_PW, USER_NAME, DESCRIPTION); @@ -572,12 +567,9 @@ void findReservations() { given(maps.findById(anyLong())) .willReturn(Optional.of(luther)); - given(reservations.findAllBySpaceIdInAndStartTimeIsBetweenAndEndTimeIsBetween( + given(reservations.findAllBySpaceIdInAndDate( anyList(), - any(LocalDateTime.class), - any(LocalDateTime.class), - any(LocalDateTime.class), - any(LocalDateTime.class))) + any(LocalDate.class))) .willReturn(foundReservations); ReservationFindDto reservationFindDto = ReservationFindDto.of( @@ -642,12 +634,9 @@ void findEmptyReservations() { .willReturn(Optional.of(luther)); given(maps.existsById(anyLong())) .willReturn(true); - given(reservations.findAllBySpaceIdInAndStartTimeIsBetweenAndEndTimeIsBetween( + given(reservations.findAllBySpaceIdInAndDate( anyList(), - any(LocalDateTime.class), - any(LocalDateTime.class), - any(LocalDateTime.class), - any(LocalDateTime.class))) + any(LocalDate.class))) .willReturn(Collections.emptyList()); //when @@ -700,12 +689,9 @@ void findAllReservation() { given(maps.findById(anyLong())) .willReturn(Optional.of(luther)); - given(reservations.findAllBySpaceIdInAndStartTimeIsBetweenAndEndTimeIsBetween( + given(reservations.findAllBySpaceIdInAndDate( anyList(), - any(LocalDateTime.class), - any(LocalDateTime.class), - any(LocalDateTime.class), - any(LocalDateTime.class))) + any(LocalDate.class))) .willReturn(foundReservations); ReservationFindAllDto reservationFindAllDto = ReservationFindAllDto.of( @@ -924,7 +910,7 @@ void updateImpossibleTimeException(int startTime, int endTime) { .willReturn(Optional.of(luther)); given(reservations.findById(anyLong())) .willReturn(Optional.of(reservation)); - given(reservations.findAllBySpaceIdInAndStartTimeIsBetweenAndEndTimeIsBetween(anyList(), any(), any(), any(), any())) + given(reservations.findAllBySpaceIdInAndDate(anyList(), any())) .willReturn(Arrays.asList( beAmZeroOne, bePmOneTwo)); @@ -991,7 +977,7 @@ void updateReservationUnable() { .availableEndTime(LocalTime.of(18, 0)) .reservationTimeUnit(10) .reservationMinimumTimeUnit(10) - .reservationMaximumTimeUnit(1440) + .reservationMaximumTimeUnit(120) .reservationEnable(false) .enabledDayOfWeek(null) .build(); @@ -1035,7 +1021,7 @@ void updateIllegalDayOfWeek() { .availableEndTime(LocalTime.of(18, 0)) .reservationTimeUnit(10) .reservationMinimumTimeUnit(10) - .reservationMaximumTimeUnit(1440) + .reservationMaximumTimeUnit(120) .reservationEnable(true) .enabledDayOfWeek(THE_DAY_AFTER_TOMORROW.plusDays(1L).getDayOfWeek().name()) .build(); diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/service/ManagerReservationServiceTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/service/ManagerReservationServiceTest.java index ce15073b6..ccc06082e 100644 --- a/backend/src/test/java/com/woowacourse/zzimkkong/service/ManagerReservationServiceTest.java +++ b/backend/src/test/java/com/woowacourse/zzimkkong/service/ManagerReservationServiceTest.java @@ -15,6 +15,7 @@ import org.junit.jupiter.params.provider.ValueSource; import org.springframework.beans.factory.annotation.Autowired; +import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; import java.util.Arrays; @@ -375,12 +376,9 @@ void saveAvailabilityException(int startMinute, int endMinute) { //given, when given(maps.findById(anyLong())) .willReturn(Optional.of(luther)); - given(reservations.findAllBySpaceIdInAndStartTimeIsBetweenAndEndTimeIsBetween( - any(), - any(LocalDateTime.class), - any(LocalDateTime.class), - any(LocalDateTime.class), - any(LocalDateTime.class))) + given(reservations.findAllBySpaceIdInAndDate( + anyList(), + any(LocalDate.class))) .willReturn(List.of(makeReservation( reservationCreateUpdateWithPasswordRequest.getStartDateTime().minusMinutes(startMinute), reservationCreateUpdateWithPasswordRequest.getEndDateTime().plusMinutes(endMinute), @@ -409,7 +407,7 @@ void saveReservationUnable() { .availableEndTime(LocalTime.of(18, 0)) .reservationTimeUnit(10) .reservationMinimumTimeUnit(10) - .reservationMaximumTimeUnit(1440) + .reservationMaximumTimeUnit(120) .reservationEnable(false) .enabledDayOfWeek(null) .build(); @@ -451,7 +449,7 @@ void saveIllegalDayOfWeek() { .availableEndTime(LocalTime.of(18, 0)) .reservationTimeUnit(10) .reservationMinimumTimeUnit(10) - .reservationMaximumTimeUnit(1440) + .reservationMaximumTimeUnit(120) .reservationEnable(true) .enabledDayOfWeek(THE_DAY_AFTER_TOMORROW.plusDays(1L).getDayOfWeek().name()) .build(); @@ -490,12 +488,9 @@ void saveSameThresholdTime(int duration) { //given, when given(maps.findById(anyLong())) .willReturn(Optional.of(luther)); - given(reservations.findAllBySpaceIdInAndStartTimeIsBetweenAndEndTimeIsBetween( + given(reservations.findAllBySpaceIdInAndDate( anyList(), - any(LocalDateTime.class), - any(LocalDateTime.class), - any(LocalDateTime.class), - any(LocalDateTime.class))) + any(LocalDate.class))) .willReturn(List.of( makeReservation( reservationCreateUpdateWithPasswordRequest.getStartDateTime().minusMinutes(duration), @@ -522,9 +517,9 @@ void saveSameThresholdTime(int duration) { } @ParameterizedTest - @ValueSource(ints = {1, 2, 3, 4, 5, 9, 15, 29}) + @CsvSource({"1,61","10,55","5,65","20,89"}) @DisplayName("예약 생성/수정 요청 시, space setting의 reservationTimeUnit이 일치하지 않으면 예외가 발생한다.") - void saveReservationTimeUnitException(int minute) { + void saveReservationTimeUnitException(int additionalStartMinute, int additionalEndMinute) { //given given(maps.findById(anyLong())) .willReturn(Optional.of(luther)); @@ -534,14 +529,14 @@ void saveReservationTimeUnitException(int minute) { //when ReservationCreateUpdateWithPasswordRequest reservationCreateUpdateWithPasswordRequest = new ReservationCreateUpdateWithPasswordRequest( - theDayAfterTomorrowTen.plusMinutes(minute), - theDayAfterTomorrowTen.plusMinutes(minute).plusMinutes(60), + theDayAfterTomorrowTen.plusMinutes(additionalStartMinute), + theDayAfterTomorrowTen.plusMinutes(additionalEndMinute), RESERVATION_PW, USER_NAME, DESCRIPTION); ReservationCreateUpdateRequest reservationCreateUpdateRequest = new ReservationCreateUpdateRequest( - theDayAfterTomorrowTen.plusMinutes(minute), - theDayAfterTomorrowTen.plusMinutes(minute).plusMinutes(60), + theDayAfterTomorrowTen.plusMinutes(additionalStartMinute), + theDayAfterTomorrowTen.plusMinutes(additionalEndMinute), USER_NAME, DESCRIPTION); @@ -637,12 +632,9 @@ void findReservations() { be)); given(maps.findById(anyLong())) .willReturn(Optional.of(luther)); - given(reservations.findAllBySpaceIdInAndStartTimeIsBetweenAndEndTimeIsBetween( + given(reservations.findAllBySpaceIdInAndDate( anyList(), - any(LocalDateTime.class), - any(LocalDateTime.class), - any(LocalDateTime.class), - any(LocalDateTime.class))) + any(LocalDate.class))) .willReturn(foundReservations); ReservationFindDto reservationFindDto = ReservationFindDto.of( @@ -732,12 +724,9 @@ void findEmptyReservations() { .willReturn(Optional.of(luther)); given(maps.existsById(anyLong())) .willReturn(true); - given(reservations.findAllBySpaceIdInAndStartTimeIsBetweenAndEndTimeIsBetween( + given(reservations.findAllBySpaceIdInAndDate( anyList(), - any(LocalDateTime.class), - any(LocalDateTime.class), - any(LocalDateTime.class), - any(LocalDateTime.class))) + any(LocalDate.class))) .willReturn(Collections.emptyList()); //when @@ -791,12 +780,9 @@ void findAllReservation() { //when given(maps.findById(anyLong())) .willReturn(Optional.of(luther)); - given(reservations.findAllBySpaceIdInAndStartTimeIsBetweenAndEndTimeIsBetween( + given(reservations.findAllBySpaceIdInAndDate( anyList(), - any(LocalDateTime.class), - any(LocalDateTime.class), - any(LocalDateTime.class), - any(LocalDateTime.class))) + any(LocalDate.class))) .willReturn(foundReservations); ReservationFindAllDto reservationFindAllDto = ReservationFindAllDto.of( @@ -819,12 +805,9 @@ void findAllReservationsNotOwner() { //given given(maps.findById(anyLong())) .willReturn(Optional.of(luther)); - given(reservations.findAllBySpaceIdInAndStartTimeIsBetweenAndEndTimeIsBetween( + given(reservations.findAllBySpaceIdInAndDate( anyList(), - any(LocalDateTime.class), - any(LocalDateTime.class), - any(LocalDateTime.class), - any(LocalDateTime.class))) + any(LocalDate.class))) .willReturn(List.of(beAmZeroOne, bePmOneTwo)); //when @@ -846,12 +829,9 @@ void findReservationsNotOwner() { //given, when given(maps.findById(anyLong())) .willReturn(Optional.of(luther)); - given(reservations.findAllBySpaceIdInAndStartTimeIsBetweenAndEndTimeIsBetween( + given(reservations.findAllBySpaceIdInAndDate( anyList(), - any(LocalDateTime.class), - any(LocalDateTime.class), - any(LocalDateTime.class), - any(LocalDateTime.class))) + any(LocalDate.class))) .willReturn(List.of(beAmZeroOne, bePmOneTwo)); ReservationFindDto reservationFindDto = ReservationFindDto.of( @@ -1068,7 +1048,7 @@ void updateImpossibleTimeException(int startTime, int endTime) { .willReturn(Optional.of(luther)); given(reservations.findById(anyLong())) .willReturn(Optional.of(reservation)); - given(reservations.findAllBySpaceIdInAndStartTimeIsBetweenAndEndTimeIsBetween(anyList(), any(), any(), any(), any())) + given(reservations.findAllBySpaceIdInAndDate(anyList(), any())) .willReturn(Arrays.asList( beAmZeroOne, bePmOneTwo)); @@ -1136,7 +1116,7 @@ void updateReservationUnable() { .availableEndTime(LocalTime.of(18, 0)) .reservationTimeUnit(10) .reservationMinimumTimeUnit(10) - .reservationMaximumTimeUnit(1440) + .reservationMaximumTimeUnit(120) .reservationEnable(false) .enabledDayOfWeek(null) .build(); @@ -1181,7 +1161,7 @@ void updateIllegalDayOfWeek() { .availableEndTime(LocalTime.of(18, 0)) .reservationTimeUnit(10) .reservationMinimumTimeUnit(10) - .reservationMaximumTimeUnit(1440) + .reservationMaximumTimeUnit(120) .reservationEnable(true) .enabledDayOfWeek(THE_DAY_AFTER_TOMORROW.plusDays(1L).getDayOfWeek().name()) .build(); diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/service/MemberServiceTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/service/MemberServiceTest.java index 8f54e37f5..8335f18dc 100644 --- a/backend/src/test/java/com/woowacourse/zzimkkong/service/MemberServiceTest.java +++ b/backend/src/test/java/com/woowacourse/zzimkkong/service/MemberServiceTest.java @@ -3,10 +3,19 @@ import com.woowacourse.zzimkkong.domain.Member; import com.woowacourse.zzimkkong.dto.member.MemberSaveRequest; import com.woowacourse.zzimkkong.dto.member.MemberSaveResponse; +import com.woowacourse.zzimkkong.dto.member.MemberUpdateRequest; +import com.woowacourse.zzimkkong.dto.member.oauth.OauthMemberSaveRequest; import com.woowacourse.zzimkkong.exception.member.DuplicateEmailException; +import com.woowacourse.zzimkkong.exception.member.ReservationExistsOnMemberException; +import com.woowacourse.zzimkkong.infrastructure.oauth.OauthHandler; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; + +import java.util.Optional; import static com.woowacourse.zzimkkong.Constants.*; import static org.assertj.core.api.Assertions.assertThat; @@ -19,6 +28,9 @@ class MemberServiceTest extends ServiceTest { @Autowired private MemberService memberService; + @MockBean + private OauthHandler oauthHandler; + @Test @DisplayName("회원이 올바르게 저장을 요청하면 저장한다.") void saveMember() { @@ -60,4 +72,91 @@ void saveMemberException() { assertThatThrownBy(() -> memberService.saveMember(memberSaveRequest)) .isInstanceOf(DuplicateEmailException.class); } + + @ParameterizedTest + @ValueSource(strings = {"GOOGLE", "GITHUB"}) + @DisplayName("소셜 로그인을 이용해 회원가입한다.") + void saveMemberByOauth(String oauth) { + //given + OauthMemberSaveRequest oauthMemberSaveRequest = new OauthMemberSaveRequest(EMAIL, ORGANIZATION, oauth); + Member member = new Member( + oauthMemberSaveRequest.getEmail(), + oauthMemberSaveRequest.getOrganization(), + oauthMemberSaveRequest.getOauthProvider() + ); + given(members.existsByEmail(anyString())) + .willReturn(false); + + //when + Member savedMember = new Member( + 1L, + member.getEmail(), + member.getOrganization(), + member.getOauthProvider()); + given(members.save(any(Member.class))) + .willReturn(savedMember); + + //then + MemberSaveResponse memberSaveResponse = MemberSaveResponse.from(savedMember); + assertThat(memberService.saveMemberByOauth(oauthMemberSaveRequest)).usingRecursiveComparison() + .isEqualTo(memberSaveResponse); + } + + + @ParameterizedTest + @ValueSource(strings = {"GOOGLE", "GITHUB"}) + @DisplayName("이미 존재하는 이메일로 소셜 로그인을 이용해 회원가입하면 에러가 발생한다.") + void saveMemberByOauthException(String oauth) { + //given + OauthMemberSaveRequest oauthMemberSaveRequest = new OauthMemberSaveRequest(EMAIL, ORGANIZATION, oauth); + + //when + given(members.existsByEmail(anyString())) + .willReturn(true); + + //then + assertThatThrownBy(() -> memberService.saveMemberByOauth(oauthMemberSaveRequest)) + .isInstanceOf(DuplicateEmailException.class); + } + + @Test + @DisplayName("회원은 자신의 정보를 수정할 수 있다.") + void updateMember() { + // given + Member member = new Member(EMAIL, PW, ORGANIZATION); + MemberUpdateRequest memberUpdateRequest = new MemberUpdateRequest("woowabros"); + + given(members.findByEmail(any(String.class))) + .willReturn(Optional.of(member)); + + // when + memberService.updateMember(member, memberUpdateRequest); + + assertThat(members.findByEmail(EMAIL).orElseThrow().getOrganization()).isEqualTo("woowabros"); + } + + @Test + @DisplayName("회원을 삭제할 수 있다.") + void deleteMember() { + // given + Member member = new Member(1L, EMAIL, PW, ORGANIZATION); + given(reservations.existsReservationsByMemberFromToday(any(Member.class))) + .willReturn(false); + + // when, then + memberService.deleteMember(member); + } + + @Test + @DisplayName("회원이 소유한 공간에 예약이 있다면 탈퇴할 수 없다.") + void deleteMemberFailWhenAnyReservationsExists() { + // given + Member member = new Member(1L, EMAIL, PW, ORGANIZATION); + given(reservations.existsReservationsByMemberFromToday(any(Member.class))) + .willReturn(true); + + // when, then + assertThatThrownBy(() -> memberService.deleteMember(member)) + .isInstanceOf(ReservationExistsOnMemberException.class); + } } diff --git a/frontend/.gitignore b/frontend/.gitignore index 5051c541d..bb43c2839 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -2,3 +2,5 @@ node_modules dist .eslintcache +.env +*.log diff --git a/frontend/.storybook/preview.js b/frontend/.storybook/preview.js index 380836518..b7fc849a7 100644 --- a/frontend/.storybook/preview.js +++ b/frontend/.storybook/preview.js @@ -1,6 +1,7 @@ import { INITIAL_VIEWPORTS } from '@storybook/addon-viewport'; import { ThemeProvider } from 'styled-components'; import { theme, GlobalStyle } from '../src/App.styles'; +import { MemoryRouter } from 'react-router'; export const parameters = { actions: { argTypesRegex: '^on[A-Z].*' }, @@ -23,4 +24,9 @@ export const decorators = [ ), + (Story) => ( + + + + ), ]; diff --git a/frontend/package.json b/frontend/package.json index beb41db36..3f9fa4b25 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "zzimkkong-frontend", - "version": "1.0.1", + "version": "1.1.0", "main": "src/index.tsx", "license": "MIT", "homepage": "https://github.com/woowacourse-teams/2021-zzimkkong", @@ -67,6 +67,7 @@ "babel-eslint": "^10.0.0", "babel-loader": "^8.2.2", "cross-env": "^7.0.3", + "dotenv-webpack": "^7.0.3", "eslint": "^7.5.0", "eslint-config-prettier": "^8.3.0", "eslint-config-react-app": "^6.0.0", @@ -93,6 +94,7 @@ "typescript": "^4.3.5", "url-loader": "^4.1.1", "webpack": "^5.42.0", + "webpack-bundle-analyzer": "^4.4.2", "webpack-cli": "^4.7.2", "webpack-dev-server": "^3.11.2" } diff --git a/frontend/public/index.html b/frontend/public/index.html index ed0059d84..1d2636a24 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -19,5 +19,6 @@
+ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index abbd8fa20..17e947214 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,17 +1,18 @@ import { createBrowserHistory } from 'history'; +import { Suspense } from 'react'; import { QueryClient, QueryClientProvider } from 'react-query'; import { ReactQueryDevtools } from 'react-query/devtools'; -import { Router, Route, Switch } from 'react-router-dom'; +import { Route, Router, Switch } from 'react-router-dom'; import { RecoilRoot } from 'recoil'; import { ThemeProvider } from 'styled-components'; import PrivateRoute from 'PrivateRoute'; +import Header from 'components/Header/Header'; import PATH from 'constants/path'; import { PRIVATE_ROUTES, PUBLIC_ROUTES } from 'constants/routes'; import { GlobalStyle, theme } from './App.styles'; import NotFound from './pages/NotFound/NotFound'; export const history = createBrowserHistory(); - export const queryClient = new QueryClient(); const App = (): JSX.Element => { @@ -21,20 +22,22 @@ const App = (): JSX.Element => { - - {PUBLIC_ROUTES.map(({ path, component }) => ( - - {component} - - ))} + }> + + {PUBLIC_ROUTES.map(({ path, component }) => ( + + {component} + + ))} - {PRIVATE_ROUTES.map(({ path, component, redirectPath }) => ( - - {component} - - ))} - - + {PRIVATE_ROUTES.map(({ path, component, redirectPath }) => ( + + {component} + + ))} + + + diff --git a/frontend/src/PrivateRoute.tsx b/frontend/src/PrivateRoute.tsx index ffb69f901..0fec8bf16 100644 --- a/frontend/src/PrivateRoute.tsx +++ b/frontend/src/PrivateRoute.tsx @@ -1,7 +1,7 @@ import { PropsWithChildren } from 'react'; import { Redirect, Route, RouteProps } from 'react-router-dom'; -import { useRecoilValue } from 'recoil'; -import accessTokenState from 'state/accessTokenState'; +import { LOCAL_STORAGE_KEY } from 'constants/storage'; +import { getLocalStorageItem } from 'utils/localStorage'; interface Props extends RouteProps { redirectPath: string; @@ -12,7 +12,10 @@ const PrivateRoute = ({ children, ...props }: PropsWithChildren): JSX.Element => { - const token = useRecoilValue(accessTokenState); + const token = getLocalStorageItem({ + key: LOCAL_STORAGE_KEY.ACCESS_TOKEN, + defaultValue: '', + }); return {token ? children : }; }; diff --git a/frontend/src/__tests__/reservation.test.tsx b/frontend/src/__tests__/reservation.test.tsx index e3595ad38..17e26aaa2 100644 --- a/frontend/src/__tests__/reservation.test.tsx +++ b/frontend/src/__tests__/reservation.test.tsx @@ -1,5 +1,4 @@ import userEvent from '@testing-library/user-event'; - import { MemoryRouter, Route, Switch } from 'react-router-dom'; import MESSAGE from 'constants/message'; import { HREF } from 'constants/path'; @@ -69,10 +68,12 @@ describe('예약 추가', () => { userEvent.type($targetDateInput, targetDate); userEvent.click($targetSpace); - const $reservationButton = await waitFor(() => screen.getByRole('link', { name: /예약하기/i })); + const $reservationButton = await waitFor(() => + screen.getByRole('button', { name: /예약하기/i }) + ); userEvent.click($reservationButton); - const $pageTitle = screen.getByTestId(/spaceName/i); + const $pageTitle = await waitFor(() => screen.getByTestId(/spaceName/i)); expect($pageTitle).toHaveTextContent('testSpace'); diff --git a/frontend/src/api/guestReservation.ts b/frontend/src/api/guestReservation.ts index 269fe991d..cc3c6febf 100644 --- a/frontend/src/api/guestReservation.ts +++ b/frontend/src/api/guestReservation.ts @@ -1,11 +1,11 @@ import { AxiosResponse } from 'axios'; import { QueryFunction, QueryKey } from 'react-query'; -import MESSAGE from 'constants/message'; +import THROW_ERROR from 'constants/throwError'; import { QueryGuestReservationsSuccess } from 'types/response'; import api from './api'; export interface QueryMapReservationsParams { - mapId: number | null; + mapId: number; date: string; } @@ -13,7 +13,7 @@ export interface QuerySpaceReservationsParams extends QueryMapReservationsParams spaceId: number; } -interface ReservationParams { +export interface ReservationParams { reservation: { startDateTime: Date; endDateTime: Date; @@ -49,7 +49,7 @@ export const queryGuestReservations: QueryFunction< const { mapId, spaceId, date } = data; if (!mapId) { - throw new Error(MESSAGE.RESERVATION.INVALID_MAP_ID); + throw new Error(THROW_ERROR.INVALID_MAP_ID); } return api.get(`/guests/maps/${mapId}/spaces/${spaceId}/reservations?date=${date}`); diff --git a/frontend/src/api/join.ts b/frontend/src/api/join.ts index cd1692748..dfedbd89b 100644 --- a/frontend/src/api/join.ts +++ b/frontend/src/api/join.ts @@ -1,5 +1,6 @@ import { AxiosResponse } from 'axios'; import { QueryFunction } from 'react-query'; +import THROW_ERROR from 'constants/throwError'; import api from './api'; interface JoinParams { @@ -8,12 +9,35 @@ interface JoinParams { organization: string; } +interface SocialJoinParams { + email: string; + organization: string; + oauthProvider: 'GITHUB' | 'GOOGLE'; +} + +export interface QueryEmailParams { + code: string; +} + export const queryValidateEmail: QueryFunction = ({ queryKey }) => { const [, email] = queryKey; - return api.get(`/members/?email=${email as string}`); + if (typeof email !== 'string') throw new Error(THROW_ERROR.INVALID_EMAIL_FORMAT); + + return api.get(`/managers?email=${email}`); }; export const postJoin = ({ email, password, organization }: JoinParams): Promise => { - return api.post('/members', { email, password, organization }); + return api.post('/managers', { email, password, organization }); }; + +export const postSocialJoin = ({ + email, + organization, + oauthProvider, +}: SocialJoinParams): Promise => + api.post(`/managers/oauth`, { + email, + organization, + oauthProvider, + }); diff --git a/frontend/src/api/login.ts b/frontend/src/api/login.ts index 1e3adbe6d..48d59ddd5 100644 --- a/frontend/src/api/login.ts +++ b/frontend/src/api/login.ts @@ -1,4 +1,6 @@ import { AxiosResponse } from 'axios'; +import { QueryFunction, QueryKey } from 'react-query'; +import { LoginSuccess } from 'types/response'; import api from './api'; interface LoginParams { @@ -6,6 +8,28 @@ interface LoginParams { password: string; } +export interface SocialLoginParams { + code: string; +} + export const postLogin = (loginData: LoginParams): Promise => { - return api.post('/login/token', loginData); + return api.post('/managers/login/token', loginData); +}; + +export const queryGithubLogin: QueryFunction< + AxiosResponse, + [QueryKey, SocialLoginParams] +> = ({ queryKey }) => { + const [, { code }] = queryKey; + + return api.get(`/managers/github/login/token?code=${code}`); +}; + +export const queryGoogleLogin: QueryFunction< + AxiosResponse, + [QueryKey, SocialLoginParams] +> = ({ queryKey }) => { + const [, { code }] = queryKey; + + return api.get(`/managers/google/login/token?code=${code}`); }; diff --git a/frontend/src/api/managerReservation.ts b/frontend/src/api/managerReservation.ts index 1919157b6..e93c631ba 100644 --- a/frontend/src/api/managerReservation.ts +++ b/frontend/src/api/managerReservation.ts @@ -1,27 +1,42 @@ import { AxiosResponse } from 'axios'; import { QueryFunction, QueryKey } from 'react-query'; -import MESSAGE from 'constants/message'; -import { QueryManagerReservationsSuccess } from 'types/response'; +import { + QueryManagerMapReservationsSuccess, + QueryManagerSpaceReservationsSuccess, +} from 'types/response'; import api from './api'; export interface QueryMapReservationsParams { - mapId: number | null; + mapId: number; date: string; } -interface ReservationParams { +export interface QueryManagerSpaceReservationsParams extends QueryMapReservationsParams { + spaceId: number; +} + +export interface PostReservationParams { + mapId: number; + spaceId: number; reservation: { startDateTime: Date; endDateTime: Date; name: string; description: string; + password: string; }; } -interface PutReservationParams extends ReservationParams { +export interface PutReservationParams { mapId: number; spaceId: number; reservationId: number; + reservation: { + startDateTime: Date; + endDateTime: Date; + name: string; + description: string; + }; } interface DeleteReservationParams { @@ -30,20 +45,33 @@ interface DeleteReservationParams { reservationId: number; } -export const queryManagerReservations: QueryFunction< - AxiosResponse, +export const queryManagerSpaceReservations: QueryFunction< + AxiosResponse, + [QueryKey, QueryManagerSpaceReservationsParams] +> = ({ queryKey }) => { + const [, data] = queryKey; + const { mapId, spaceId, date } = data; + + return api.get(`/managers/maps/${mapId}/spaces/${spaceId}/reservations?date=${date}`); +}; + +export const queryManagerMapReservations: QueryFunction< + AxiosResponse, [QueryKey, QueryMapReservationsParams] > = ({ queryKey }) => { const [, data] = queryKey; const { mapId, date } = data; - if (!mapId) { - throw new Error(MESSAGE.RESERVATION.INVALID_MAP_ID); - } - return api.get(`/managers/maps/${mapId}/spaces/reservations?date=${date}`); }; +export const postManagerReservation = ({ + reservation, + mapId, + spaceId, +}: PostReservationParams): Promise> => + api.post(`/managers/maps/${mapId}/spaces/${spaceId}/reservations`, reservation); + export const putManagerReservation = ({ reservation, mapId, diff --git a/frontend/src/api/managerSpaces.ts b/frontend/src/api/managerSpaces.ts index 72b1ad7ed..dca178a45 100644 --- a/frontend/src/api/managerSpaces.ts +++ b/frontend/src/api/managerSpaces.ts @@ -1,5 +1,6 @@ import { AxiosResponse } from 'axios'; import { QueryFunction, QueryKey } from 'react-query'; +import THROW_ERROR from 'constants/throwError'; import { QueryManagerSpacesSuccess } from 'types/response'; import api from './api'; @@ -14,5 +15,9 @@ export const queryManagerSpaces: QueryFunction< const [, data] = queryKey; const { mapId } = data; + if (!mapId) { + throw new Error(THROW_ERROR.INVALID_MAP_ID); + } + return api.get(`/managers/maps/${mapId}/spaces`); }; diff --git a/frontend/src/api/presets.ts b/frontend/src/api/presets.ts index b4a1c51d1..0d65656f6 100644 --- a/frontend/src/api/presets.ts +++ b/frontend/src/api/presets.ts @@ -14,13 +14,13 @@ export interface DeletePresetParams { } export const queryPresets: QueryFunction, [QueryKey]> = () => - api.get('/members/presets'); + api.get('/managers/presets'); export const postPreset = ({ name, settingsRequest, }: PostPresetParams): Promise> => - api.post('/members/presets', { name, settingsRequest }); + api.post('/managers/presets', { name, settingsRequest }); export const deletePreset = ({ id }: DeletePresetParams): Promise> => - api.delete(`/members/presets/${id}`); + api.delete(`/managers/presets/${id}`); diff --git a/frontend/src/assets/svg/github-logo.svg b/frontend/src/assets/svg/github-logo.svg new file mode 100644 index 000000000..4f0031f56 --- /dev/null +++ b/frontend/src/assets/svg/github-logo.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/assets/svg/google-logo.svg b/frontend/src/assets/svg/google-logo.svg new file mode 100644 index 000000000..55521b9fb --- /dev/null +++ b/frontend/src/assets/svg/google-logo.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/components/Board/Board.styles.ts b/frontend/src/components/Board/Board.styles.ts new file mode 100644 index 000000000..48318e08f --- /dev/null +++ b/frontend/src/components/Board/Board.styles.ts @@ -0,0 +1,24 @@ +import styled from 'styled-components'; + +interface RootSvgProps { + movable: boolean; + isMoving: boolean; +} + +export const RootSvg = styled.svg` + cursor: ${({ movable, isMoving }) => { + if (movable) { + if (isMoving) return 'grabbing'; + else return 'grab'; + } + return 'default'; + }}; +`; + +export const BoardContainerBackground = styled.rect``; + +export const Board = styled.svg``; + +export const BoardGroup = styled.g``; + +export const BoardBackground = styled.rect``; diff --git a/frontend/src/components/Board/Board.tsx b/frontend/src/components/Board/Board.tsx new file mode 100644 index 000000000..e1ffe6b47 --- /dev/null +++ b/frontend/src/components/Board/Board.tsx @@ -0,0 +1,101 @@ +import React, { PropsWithChildren, useLayoutEffect, useRef } from 'react'; +import PALETTE from 'constants/palette'; +import { EditorBoard } from 'types/common'; +import * as Styled from './Board.styles'; +import GridPattern from './GridPattern'; + +interface Props { + boardState: [EditorBoard, React.Dispatch>]; + movable?: boolean; + isMoving?: boolean; + onClick?: (event: React.MouseEvent) => void; + onMouseMove?: (event: React.MouseEvent) => void; + onMouseDown?: (event: React.MouseEvent) => void; + onMouseUp?: (event: React.MouseEvent) => void; + onDragStart?: (event: React.MouseEvent) => void; + onDrag?: (event: React.MouseEvent) => void; + onDragEnd?: (event: React.MouseEvent) => void; + onMouseOut?: (event: React.MouseEvent) => void; + onWheel?: (event: React.WheelEvent) => void; +} + +const Board = ({ + boardState, + movable = false, + isMoving = false, + onClick, + onMouseMove, + onMouseDown, + onMouseUp, + onDragStart, + onDrag, + onDragEnd, + onMouseOut, + onWheel, + children, +}: PropsWithChildren): JSX.Element => { + const rootSvgRef = useRef(null); + const [board, setBoard] = boardState; + + const handleMouseMove = (event: React.MouseEvent) => { + onMouseMove?.(event); + }; + + useLayoutEffect(() => { + const boardWidth = rootSvgRef.current?.clientWidth ?? 0; + const boardHeight = rootSvgRef.current?.clientHeight ?? 0; + + setBoard((prevStatus) => ({ + ...prevStatus, + x: (boardWidth - board.width) / 2, + y: (boardHeight - board.height) / 2, + })); + }, [setBoard, board.height, board.width]); + + return ( + + + + + + + + + + + + {children} + + + + ); +}; + +export default Board; diff --git a/frontend/src/components/Board/GridPattern.tsx b/frontend/src/components/Board/GridPattern.tsx new file mode 100644 index 000000000..a429b1101 --- /dev/null +++ b/frontend/src/components/Board/GridPattern.tsx @@ -0,0 +1,51 @@ +import { EDITOR } from 'constants/editor'; +import PALETTE from 'constants/palette'; + +interface BoardSize { + width: number; + height: number; +} + +const GridPatternDefs = () => ( + + + + + + + + + +); + +const GridPattern = ({ width, height }: BoardSize): JSX.Element => ( + +); + +GridPattern.Defs = GridPatternDefs; + +export default GridPattern; diff --git a/frontend/src/components/Button/Button.styles.ts b/frontend/src/components/Button/Button.styles.ts index 0d81e4963..9fa768105 100644 --- a/frontend/src/components/Button/Button.styles.ts +++ b/frontend/src/components/Button/Button.styles.ts @@ -3,7 +3,7 @@ import styled, { css } from 'styled-components'; interface Props { variant: 'primary' | 'primary-text' | 'text' | 'default'; shape: 'default' | 'round'; - size: 'small' | 'medium' | 'large'; + size: 'dense' | 'small' | 'medium' | 'large'; fullWidth: boolean; } @@ -47,6 +47,9 @@ const shapeCSS = { }; const sizeCSS = { + dense: css` + padding: 0 0.5rem; + `, small: css` padding: 0.25rem 0.5rem; `, diff --git a/frontend/src/components/Button/Button.tsx b/frontend/src/components/Button/Button.tsx index 974d03acd..235716037 100644 --- a/frontend/src/components/Button/Button.tsx +++ b/frontend/src/components/Button/Button.tsx @@ -4,7 +4,7 @@ import * as Styled from './Button.styles'; export interface Props extends ButtonHTMLAttributes { variant?: 'primary' | 'primary-text' | 'text' | 'default'; shape?: 'default' | 'round'; - size?: 'small' | 'medium' | 'large'; + size?: 'dense' | 'small' | 'medium' | 'large'; fullWidth?: boolean; } diff --git a/frontend/src/components/ColorDot/ColorDot.ts b/frontend/src/components/ColorDot/ColorDot.ts new file mode 100644 index 000000000..ad0533e2b --- /dev/null +++ b/frontend/src/components/ColorDot/ColorDot.ts @@ -0,0 +1,27 @@ +import styled, { css } from 'styled-components'; +import { Color } from 'types/common'; + +interface ColorDotProps { + color: Color; + size?: 'medium' | 'large'; +} + +const colorDotSizeCSS = { + medium: css` + width: 1rem; + height: 1rem; + `, + large: css` + width: 1.5rem; + height: 1.5rem; + `, +}; + +const ColorDot = styled.span` + display: inline-block; + background-color: ${({ color }) => color}; + border-radius: 50%; + ${({ size = 'medium' }) => colorDotSizeCSS[size]}; +`; + +export default ColorDot; diff --git a/frontend/src/components/Input/Input.styles.ts b/frontend/src/components/Input/Input.styles.ts index 89878591b..0aad51464 100644 --- a/frontend/src/components/Input/Input.styles.ts +++ b/frontend/src/components/Input/Input.styles.ts @@ -68,11 +68,15 @@ export const Input = styled.input` border-color: ${({ theme }) => theme.primary[400]}; box-shadow: inset 0px 0px 0px 1px ${({ theme }) => theme.primary[400]}; } + + &:disabled { + color: ${({ theme }) => theme.gray[400]}; + } `; export const Message = styled.p` ${({ status = 'default' }) => statusCSS[status]}; font-size: 0.75rem; position: absolute; - margin: 0.25rem 0.75rem; + margin: 0.25rem 0.5rem; `; diff --git a/frontend/src/components/Modal/Modal.tsx b/frontend/src/components/Modal/Modal.tsx index 2cf5052ee..23f271958 100644 --- a/frontend/src/components/Modal/Modal.tsx +++ b/frontend/src/components/Modal/Modal.tsx @@ -1,4 +1,5 @@ import { MouseEventHandler, PropsWithChildren } from 'react'; +import { createPortal } from 'react-dom'; import { ReactComponent as CloseIcon } from 'assets/svg/close.svg'; import * as Styled from './Modal.styles'; @@ -9,6 +10,8 @@ export interface Props { onClose: () => void; } +let modalRoot = document.getElementById('modal'); + const Modal = ({ open, isClosableDimmer, @@ -16,13 +19,20 @@ const Modal = ({ onClose, children, }: PropsWithChildren): JSX.Element => { + if (modalRoot === null) { + // Note: 테스트(Jest)에서 modalRoot를 인식하지 못하는 문제해결 + modalRoot = document.createElement('div'); + modalRoot.setAttribute('id', 'modal'); + document.body.appendChild(modalRoot); + } + const handleMouseDownOverlay: MouseEventHandler = ({ target, currentTarget }) => { if (isClosableDimmer && target === currentTarget) { onClose(); } }; - return ( + return createPortal( {open && showCloseButton && ( @@ -32,7 +42,8 @@ const Modal = ({ )} {open && children} - + , + modalRoot ); }; diff --git a/frontend/src/components/SocialAuthButton/SociaLoginButton.stories.tsx b/frontend/src/components/SocialAuthButton/SociaLoginButton.stories.tsx new file mode 100644 index 000000000..bbcc1dc69 --- /dev/null +++ b/frontend/src/components/SocialAuthButton/SociaLoginButton.stories.tsx @@ -0,0 +1,36 @@ +import { Story } from '@storybook/react'; +import { PropsWithChildren } from 'react'; +import SocialLoginButton, { Props } from './SocialLoginButton'; + +export default { + title: 'shared/SocialLoginButton', + component: SocialLoginButton, + argTypes: { + provider: { + options: ['GITHUB', 'GOOGLE'], + control: { type: 'radio' }, + }, + }, +}; + +const Template: Story> = (args) => ; + +export const GithubLogin = Template.bind({}); +GithubLogin.args = { + provider: 'GITHUB', +}; + +export const GoogleLogin = Template.bind({}); +GoogleLogin.args = { + provider: 'GOOGLE', +}; + +export const GithubJoin = Template.bind({}); +GithubJoin.args = { + provider: 'GITHUB', +}; + +export const GoogleJoin = Template.bind({}); +GoogleJoin.args = { + provider: 'GOOGLE', +}; diff --git a/frontend/src/components/SocialAuthButton/SocialAuthButton.styles.ts b/frontend/src/components/SocialAuthButton/SocialAuthButton.styles.ts new file mode 100644 index 000000000..944ed828d --- /dev/null +++ b/frontend/src/components/SocialAuthButton/SocialAuthButton.styles.ts @@ -0,0 +1,48 @@ +import styled, { css } from 'styled-components'; +import PALETTE from 'constants/palette'; +import { Props as JoinButtonProps } from './SocialJoinButton'; +import { Props as LoginButtonProps } from './SocialLoginButton'; + +const providerCSS = { + GITHUB: css` + background-color: ${PALETTE.GITHUB}; + color: ${PALETTE.WHITE}; + border: none; + `, + GOOGLE: css` + background-color: ${PALETTE.WHITE}; + color: ${PALETTE.BLACK[700]}; + border: 1px solid ${PALETTE.BLACK[700]}; + `, +}; + +const buttonCSS = css` + display: flex; + justify-content: center; + align-items: center; + gap: 1rem; + text-decoration: none; + padding: 0.75rem 1rem; + font-size: 1.25rem; + border-radius: 0.125rem; +`; + +export const SocialLoginButton = styled.a` + ${({ provider }) => providerCSS[provider]} + ${buttonCSS}; +`; + +export const SocialJoinButton = styled.button` + ${({ provider }) => providerCSS[provider]} + ${buttonCSS}; + width: 100%; + cursor: pointer; +`; + +export const Icon = styled.div` + display: inline-flex; + align-items: center; + justify-content: flex-end; +`; + +export const Text = styled.div``; diff --git a/frontend/src/components/SocialAuthButton/SocialJoinButton.stories.tsx b/frontend/src/components/SocialAuthButton/SocialJoinButton.stories.tsx new file mode 100644 index 000000000..4e5045153 --- /dev/null +++ b/frontend/src/components/SocialAuthButton/SocialJoinButton.stories.tsx @@ -0,0 +1,26 @@ +import { Story } from '@storybook/react'; +import { PropsWithChildren } from 'react'; +import SocialJoinButton, { Props } from './SocialJoinButton'; + +export default { + title: 'shared/SocialJoinButton', + component: SocialJoinButton, + argTypes: { + provider: { + options: ['GITHUB', 'GOOGLE'], + control: { type: 'radio' }, + }, + }, +}; + +const Template: Story> = (args) => ; + +export const GithubJoin = Template.bind({}); +GithubJoin.args = { + provider: 'GITHUB', +}; + +export const GoogleJoin = Template.bind({}); +GoogleJoin.args = { + provider: 'GOOGLE', +}; diff --git a/frontend/src/components/SocialAuthButton/SocialJoinButton.tsx b/frontend/src/components/SocialAuthButton/SocialJoinButton.tsx new file mode 100644 index 000000000..a10ae42e4 --- /dev/null +++ b/frontend/src/components/SocialAuthButton/SocialJoinButton.tsx @@ -0,0 +1,30 @@ +import { ButtonHTMLAttributes } from 'react'; +import { ReactComponent as GithubIcon } from 'assets/svg/github-logo.svg'; +import { ReactComponent as GoogleIcon } from 'assets/svg/google-logo.svg'; +import * as Styled from './SocialAuthButton.styles'; + +export interface Props extends ButtonHTMLAttributes { + provider: 'GITHUB' | 'GOOGLE'; +} + +const social = { + GITHUB: { + icon: , + text: 'Github로 시작하기', + }, + GOOGLE: { + icon: , + text: 'Google로 시작하기', + }, +}; + +const SocialJoinButton = ({ provider, ...props }: Props): JSX.Element => { + return ( + + {social[provider].icon} + {social[provider].text} + + ); +}; + +export default SocialJoinButton; diff --git a/frontend/src/components/SocialAuthButton/SocialLoginButton.tsx b/frontend/src/components/SocialAuthButton/SocialLoginButton.tsx new file mode 100644 index 000000000..0cb305f2c --- /dev/null +++ b/frontend/src/components/SocialAuthButton/SocialLoginButton.tsx @@ -0,0 +1,30 @@ +import { AnchorHTMLAttributes } from 'react'; +import { ReactComponent as GithubIcon } from 'assets/svg/github-logo.svg'; +import { ReactComponent as GoogleIcon } from 'assets/svg/google-logo.svg'; +import * as Styled from './SocialAuthButton.styles'; + +export interface Props extends AnchorHTMLAttributes { + provider: 'GITHUB' | 'GOOGLE'; +} + +const social = { + GITHUB: { + icon: , + text: 'Github로 로그인', + }, + GOOGLE: { + icon: , + text: 'Google로 로그인', + }, +}; + +const SocialLoginButton = ({ provider, ...props }: Props): JSX.Element => { + return ( + + {social[provider].icon} + {social[provider].text} + + ); +}; + +export default SocialLoginButton; diff --git a/frontend/src/components/Toggle/Toggle.stories.tsx b/frontend/src/components/Toggle/Toggle.stories.tsx index ad11c294a..7eeac31e3 100644 --- a/frontend/src/components/Toggle/Toggle.stories.tsx +++ b/frontend/src/components/Toggle/Toggle.stories.tsx @@ -23,7 +23,7 @@ export const Default = Template.bind({}); Default.args = { variant: 'default', checked: false, - text: '토글 비활성화됨', + uncheckedText: '토글 비활성화됨', checkedText: '토글 활성화됨', textPosition: 'left', }; @@ -32,7 +32,7 @@ export const Primary = Template.bind({}); Primary.args = { variant: 'primary', checked: false, - text: '토글 비활성화됨', + uncheckedText: '토글 비활성화됨', checkedText: '토글 활성화됨', textPosition: 'right', }; diff --git a/frontend/src/components/Toggle/Toggle.tsx b/frontend/src/components/Toggle/Toggle.tsx index 46b8f80ae..3651c23b8 100644 --- a/frontend/src/components/Toggle/Toggle.tsx +++ b/frontend/src/components/Toggle/Toggle.tsx @@ -3,14 +3,14 @@ import * as Styled from './Toggle.styles'; export interface Props extends InputHTMLAttributes { variant?: 'default' | 'primary'; - text: string; + uncheckedText: string; checkedText: string; textPosition?: 'left' | 'right'; } const Toggle = ({ variant = 'default', - text, + uncheckedText, checkedText, checked, textPosition = 'right', @@ -19,14 +19,14 @@ const Toggle = ({ return ( {textPosition === 'left' && ( - {checked ? checkedText : text} + {checked ? checkedText : uncheckedText} )} {textPosition === 'right' && ( - {checked ? checkedText : text} + {checked ? checkedText : uncheckedText} )} ); diff --git a/frontend/src/constants/editor.ts b/frontend/src/constants/editor.ts index 337e5dd9d..4207e1cd8 100644 --- a/frontend/src/constants/editor.ts +++ b/frontend/src/constants/editor.ts @@ -1,18 +1,5 @@ import PALETTE from './palette'; -export enum Mode { - SELECT = 'select', - MOVE = 'move', - LINE = 'line', - POLYLINE = 'polyline', - DECORATION = 'decoration', -} - -export enum DrawingAreaShape { - RECT = 'rect', - POLYGON = 'polygon', -} - export const MAP_COLOR_PALETTE = [ PALETTE.BLACK[400], PALETTE.GRAY[400], @@ -25,6 +12,8 @@ export const MAP_COLOR_PALETTE = [ ]; export const BOARD = { + DEFAULT_WIDTH: 800, + DEFAULT_HEIGHT: 600, MAX_WIDTH: 5000, MIN_WIDTH: 100, MAX_HEIGHT: 5000, @@ -43,4 +32,13 @@ export const EDITOR = { MIN_SCALE: 0.5, MAX_SCALE: 3.0, STROKE_WIDTH: 3, + STROKE_PREVIEW: PALETTE.OPACITY_BLACK[200], + OPACITY: 1, + OPACITY_DELETING: 0.3, + TEXT_OPACITY: 0.3, + TEXT_FONT_SIZE: '1rem', + TEXT_FILL: PALETTE.BLACK[700], + SPACE_OPACITY: 0.1, + CIRCLE_CURSOR_RADIUS: 3, + CIRCLE_CURSOR_FILL: PALETTE.OPACITY_BLACK[300], }; diff --git a/frontend/src/constants/message.ts b/frontend/src/constants/message.ts index 239a15f26..47ee5d314 100644 --- a/frontend/src/constants/message.ts +++ b/frontend/src/constants/message.ts @@ -15,11 +15,16 @@ const MESSAGE = { UNEXPECTED_ERROR: '로그인에 문제가 발생했습니다. 잠시 후에 다시 시도해주세요.', }, RESERVATION: { + CREATE: '예약하기', + EDIT: '예약 수정하기', + SUGGESTION: '오늘의 첫 예약을 잡아보세요!', + PENDING: '불러오는 중입니다...', + ERROR: `예약 목록을 불러오는 데 문제가 생겼어요!\n새로 고침으로 다시 시도해주세요.`, DELETE_SUCCESS: '예약이 삭제 되었습니다.', UNEXPECTED_ERROR: '예약하는 중에 문제가 발생했습니다. 잠시 후에 다시 시도해주세요.', UNEXPECTED_DELETE_ERROR: '예약을 삭제하는 중에 문제가 발생했습니다. 잠시 후에 다시 시도해주세요.', - INVALID_MAP_ID: '맵 ID가 올바르지 않습니다. 다시 확인해주세요.', + PASSWORD_MESSAGE: '숫자 4자리를 입력해주세요.', }, MANAGER_MAIN: { UNEXPECTED_GET_DATA_ERROR: @@ -49,7 +54,7 @@ const MESSAGE = { '프리셋을 삭제하는 중에 문제가 발생했습니다. 잠시 후에 다시 시도해주세요.', SPACE_CREATED: '공간이 생성되었습니다.', SPACE_SETTING_UPDATED: '공간 설정이 수정되었습니다.', - SPACE_DELETED: '공간이 생성되었습니다.', + SPACE_DELETED: '공간이 삭제되었습니다.', PRESET_CREATED: '프리셋이 추가되었습니다.', PRESET_DELETED: '프리셋이 삭제되었습니다.', DELETE_PRESET_CONFIRM: '이 프리셋을 삭제하시겠어요?', @@ -60,6 +65,11 @@ const MESSAGE = { CANCEL_CONFIRM: '편집 중인 맵은 저장되지 않으며, 메인 페이지로 돌아갑니다.', UNEXPECTED_MAP_CREATE_ERROR: '맵을 생성하는 중에 문제가 발생했습니다. 잠시 후에 다시 시도해주세요.', + UNEXPECTED_MAP_UPDATE_ERROR: + '맵을 수정하는 중에 문제가 발생했습니다. 잠시 후에 다시 시도해주세요.', + }, + GUEST_MAP: { + MAP_DRAWING_PARSE_ERROR: '맵을 불러오는 중에 문제가 발생했습니다. 잠시 후에 다시 시도해주세요.', }, }; diff --git a/frontend/src/constants/palette.ts b/frontend/src/constants/palette.ts index 248e5c94a..2e6d0439f 100644 --- a/frontend/src/constants/palette.ts +++ b/frontend/src/constants/palette.ts @@ -106,6 +106,8 @@ const PALETTE = { 900: 'rgba(0, 0, 0, 0.9)', }, WHITE: '#fff', + + GITHUB: '#24292F', }; export default PALETTE; diff --git a/frontend/src/constants/path.ts b/frontend/src/constants/path.ts index 211c1dd1d..eaf74b626 100644 --- a/frontend/src/constants/path.ts +++ b/frontend/src/constants/path.ts @@ -1,8 +1,34 @@ +const GITHUB_OAUTH_KEY = (() => { + if (process.env.NODE_ENV === 'development' && process.env.DEPLOY_ENV === 'development') { + return process.env.GITHUB_OAUTH_KEY_LOCAL ?? ''; + } + + if (process.env.NODE_ENV === 'production' && process.env.DEPLOY_ENV === 'development') { + return process.env.GITHUB_OAUTH_KEY_DEV ?? ''; + } + + if (process.env.NODE_ENV === 'production' && process.env.DEPLOY_ENV === 'production') { + return process.env.GITHUB_OAUTH_KEY_PROD ?? ''; + } + + return ''; +})(); + +const GOOGLE_OAUTH_KEY = process.env.GOOGLE_OAUTH_KEY ?? ''; + +const REDIRECT_URI = `${process.env.NODE_ENV === 'production' ? 'https://' : 'http://'}${ + window.location.hostname +}${window.location.port ? `:${window.location.port}` : ''}`; + const PATH = { MAIN: '/', MANAGER_LOGIN: '/login', MANAGER_JOIN: '/join', + MANAGER_SOCIAL_JOIN: '/join/social', + MANAGER_GITHUB_OAUTH_REDIRECT: '/login/oauth/github', + MANAGER_GOOGLE_OAUTH_REDIRECT: '/login/oauth/google', MANAGER_MAIN: '/map', + MANAGER_RESERVATION: '/reservation', MANAGER_RESERVATION_EDIT: '/reservation/edit', MANAGER_MAP_CREATE: '/map/create', MANAGER_MAP_EDIT: '/map/:mapId/edit', @@ -10,7 +36,17 @@ const PATH = { GUEST_MAP: '/guest/:sharingMapId', GUEST_RESERVATION: '/guest/:sharingMapId/reservation', GUEST_RESERVATION_EDIT: '/guest/:sharingMapId/reservation/edit', - NOT_FOUND: ['*', '/not-found'], + NOT_FOUND: '/not-found', + GITHUB_LOGIN: `https://github.com/login/oauth/authorize?client_id=${GITHUB_OAUTH_KEY}&redirect_uri=${REDIRECT_URI}/login/oauth/github`, + GOOGLE_LOGIN: + 'https://accounts.google.com/o/oauth2/v2/auth?' + + 'scope=https://www.googleapis.com/auth/userinfo.email&' + + 'access_type=offline&' + + 'include_granted_scopes=true&' + + 'response_type=code&' + + 'state=state_parameter_passthrough_value&' + + `redirect_uri=${REDIRECT_URI}/login/oauth/google&` + + `client_id=${GOOGLE_OAUTH_KEY}`, }; export const HREF = { @@ -19,6 +55,8 @@ export const HREF = { PATH.MANAGER_SPACE_EDIT.replace(':mapId', `${mapId}`), GUEST_MAP: (sharingMapId: string): string => PATH.GUEST_MAP.replace(':sharingMapId', `${sharingMapId}`), + GUEST_RESERVATION: (sharingMapId: string): string => + PATH.GUEST_RESERVATION.replace(':sharingMapId', `${sharingMapId}`), }; export default PATH; diff --git a/frontend/src/constants/routes.tsx b/frontend/src/constants/routes.tsx index 92c9bcba1..acfddfc63 100644 --- a/frontend/src/constants/routes.tsx +++ b/frontend/src/constants/routes.tsx @@ -1,16 +1,19 @@ -import { ReactNode } from 'react'; -import GuestMap from 'pages/GuestMap/GuestMap'; -import GuestReservation from 'pages/GuestReservation/GuestReservation'; -import GuestReservationEdit from 'pages/GuestReservationEdit/GuestReservationEdit'; -import Main from 'pages/Main/Main'; -import ManagerJoin from 'pages/ManagerJoin/ManagerJoin'; -import ManagerLogin from 'pages/ManagerLogin/ManagerLogin'; -import ManagerMain from 'pages/ManagerMain/ManagerMain'; -import ManagerMapCreate from 'pages/ManagerMapCreate/ManagerMapCreate'; -import ManagerReservationEdit from 'pages/ManagerReservationEdit/ManagerReservationEdit'; -import ManagerSpaceEdit from 'pages/ManagerSpaceEdit/ManagerSpaceEdit'; +import React, { ReactNode } from 'react'; import PATH from './path'; +const GuestMap = React.lazy(() => import('pages/GuestMap/GuestMap')); +const GuestReservation = React.lazy(() => import('pages/GuestReservation/GuestReservation')); +const Main = React.lazy(() => import('pages/Main/Main')); +const ManagerJoin = React.lazy(() => import('pages/ManagerJoin/ManagerJoin')); +const ManagerSocialJoin = React.lazy(() => import('pages/ManagerSocialJoin/ManagerSocialJoin')); +const ManagerLogin = React.lazy(() => import('pages/ManagerLogin/ManagerLogin')); +const ManagerMain = React.lazy(() => import('pages/ManagerMain/ManagerMain')); +const ManagerMapEditor = React.lazy(() => import('pages/ManagerMapEditor/ManagerMapEditor')); +const ManagerReservation = React.lazy(() => import('pages/ManagerReservation/ManagerReservation')); +const ManagerSpaceEditor = React.lazy(() => import('pages/ManagerSpaceEditor/ManagerSpaceEditor')); +const GithubOAuthRedirect = React.lazy(() => import('pages/OAuthRedirect/GithubOAuthRedirect')); +const GoogleOAuthRedirect = React.lazy(() => import('pages/OAuthRedirect/GoogleOAuthRedirect')); + interface Route { path: string; component: ReactNode; @@ -33,6 +36,18 @@ export const PUBLIC_ROUTES: Route[] = [ path: PATH.MANAGER_JOIN, component: , }, + { + path: PATH.MANAGER_SOCIAL_JOIN, + component: , + }, + { + path: PATH.MANAGER_GITHUB_OAUTH_REDIRECT, + component: , + }, + { + path: PATH.MANAGER_GOOGLE_OAUTH_REDIRECT, + component: , + }, { path: PATH.GUEST_MAP, component: , @@ -43,7 +58,7 @@ export const PUBLIC_ROUTES: Route[] = [ }, { path: PATH.GUEST_RESERVATION_EDIT, - component: , + component: , }, ]; @@ -53,24 +68,29 @@ export const PRIVATE_ROUTES: PrivateRoute[] = [ component: , redirectPath: PATH.MANAGER_LOGIN, }, + { + path: PATH.MANAGER_RESERVATION, + component: , + redirectPath: PATH.MANAGER_LOGIN, + }, { path: PATH.MANAGER_RESERVATION_EDIT, - component: , + component: , redirectPath: PATH.MANAGER_LOGIN, }, { path: PATH.MANAGER_MAP_CREATE, - component: , + component: , redirectPath: PATH.MANAGER_LOGIN, }, { path: PATH.MANAGER_MAP_EDIT, - component: , + component: , redirectPath: PATH.MANAGER_LOGIN, }, { path: PATH.MANAGER_SPACE_EDIT, - component: , + component: , redirectPath: PATH.MANAGER_LOGIN, }, ]; diff --git a/frontend/src/constants/throwError.ts b/frontend/src/constants/throwError.ts new file mode 100644 index 000000000..52a5ad4c3 --- /dev/null +++ b/frontend/src/constants/throwError.ts @@ -0,0 +1,10 @@ +const THROW_ERROR = { + INVALID_EMAIL_FORMAT: '이메일은 "string" 형식이어야 합니다.', + INVALID_MAP_ID: '맵 ID가 올바르지 않습니다. 다시 확인해주세요.', + NOT_EXIST_CONTEXT: 'context가 존재하지 않습니다.', + NOT_EXIST_PRESET: '프리셋을 찾을 수 없습니다.', + NOT_JSON_FORMAT: '저장된 데이터가 JSON 형식이 아닙니다.', + NOT_MATCHED_JSON: 'item이 JSON 형식과 일치하지 않습니다.', +}; + +export default THROW_ERROR; diff --git a/frontend/src/constants/time.ts b/frontend/src/constants/time.ts new file mode 100644 index 000000000..f0d72fc07 --- /dev/null +++ b/frontend/src/constants/time.ts @@ -0,0 +1,7 @@ +const TIME = { + SECONDS_PER_MINUTE: 60, + MILLISECONDS_PER_SECOND: 1000, + MILLISECONDS_PER_MINUTE: 60000, +}; + +export default TIME; diff --git a/frontend/src/hooks/board/useBindKeyPress.ts b/frontend/src/hooks/board/useBindKeyPress.ts new file mode 100644 index 000000000..23e8da785 --- /dev/null +++ b/frontend/src/hooks/board/useBindKeyPress.ts @@ -0,0 +1,27 @@ +import { useCallback, useEffect, useState } from 'react'; + +const useBindKeyPress = (): { pressedKey: string } => { + const [pressedKey, setPressedKey] = useState(''); + + const handleKeyDown = useCallback((event: KeyboardEvent) => { + setPressedKey(event.key); + }, []); + + const handleKeyUp = useCallback(() => { + setPressedKey(''); + }, []); + + useEffect(() => { + document.addEventListener('keydown', handleKeyDown); + document.addEventListener('keyup', handleKeyUp); + + return () => { + document.removeEventListener('keydown', handleKeyDown); + document.removeEventListener('keyup', handleKeyUp); + }; + }, [handleKeyDown, handleKeyUp]); + + return { pressedKey }; +}; + +export default useBindKeyPress; diff --git a/frontend/src/hooks/board/useBoardCoordinate.ts b/frontend/src/hooks/board/useBoardCoordinate.ts new file mode 100644 index 000000000..536a403f7 --- /dev/null +++ b/frontend/src/hooks/board/useBoardCoordinate.ts @@ -0,0 +1,47 @@ +import React, { useState } from 'react'; +import { EDITOR } from 'constants/editor'; +import { Coordinate, EditorBoard } from 'types/common'; + +const useBoardCoordinate = ( + boardStatus: EditorBoard +): { + coordinate: Coordinate; + stickyDotCoordinate: Coordinate; + stickyRectCoordinate: Coordinate; + onMouseMove: (event: React.MouseEvent) => void; +} => { + const [coordinate, setCoordinate] = useState({ x: 0, y: 0 }); + const stickyDotCoordinate: Coordinate = { + x: Math.round(coordinate.x / EDITOR.GRID_SIZE) * EDITOR.GRID_SIZE, + y: Math.round(coordinate.y / EDITOR.GRID_SIZE) * EDITOR.GRID_SIZE, + }; + const stickyRectCoordinate: Coordinate = { + x: Math.floor(coordinate.x / EDITOR.GRID_SIZE) * EDITOR.GRID_SIZE, + y: Math.floor(coordinate.y / EDITOR.GRID_SIZE) * EDITOR.GRID_SIZE, + }; + + const getSVGCoordinate = (event: React.MouseEvent) => { + const svg = (event.nativeEvent.target as SVGSVGElement)?.ownerSVGElement; + if (!svg) return { x: -1, y: -1 }; + + let point = svg.createSVGPoint(); + + point.x = event.nativeEvent.clientX; + point.y = event.nativeEvent.clientY; + point = point.matrixTransform(svg.getScreenCTM()?.inverse()); + + const x = (point.x - boardStatus.x) * (1 / boardStatus.scale); + const y = (point.y - boardStatus.y) * (1 / boardStatus.scale); + + return { x, y }; + }; + + const onMouseMove = (event: React.MouseEvent) => { + const { x, y } = getSVGCoordinate(event); + setCoordinate({ x, y }); + }; + + return { coordinate, stickyDotCoordinate, stickyRectCoordinate, onMouseMove }; +}; + +export default useBoardCoordinate; diff --git a/frontend/src/hooks/board/useBoardMove.ts b/frontend/src/hooks/board/useBoardMove.ts new file mode 100644 index 000000000..3f826deca --- /dev/null +++ b/frontend/src/hooks/board/useBoardMove.ts @@ -0,0 +1,61 @@ +import { useState } from 'react'; +import { Coordinate, EditorBoard } from 'types/common'; + +const useBoardMove = ( + boardState: [EditorBoard, React.Dispatch>], + movable: boolean +): { + isMoving: boolean; + onMouseOut: () => void; + onDragStart: (event: React.MouseEvent) => void; + onDrag: (event: React.MouseEvent) => void; + onDragEnd: () => void; +} => { + const [board, setBoard] = boardState; + + const [isMoving, setIsMoving] = useState(false); + const [dragOffset, setDragOffset] = useState(null); + + const onMouseOut = () => { + setIsMoving(false); + }; + + const onDragStart = (event: React.MouseEvent) => { + if (!movable) return; + + const dragOffsetX = event.nativeEvent.offsetX - board.x; + const dragOffsetY = event.nativeEvent.offsetY - board.y; + + setIsMoving(true); + setDragOffset({ x: dragOffsetX, y: dragOffsetY }); + }; + + const onDrag = (event: React.MouseEvent) => { + if (!movable || !isMoving || dragOffset === null) return; + + const { offsetX, offsetY } = event.nativeEvent; + + setBoard((prevState) => ({ + ...prevState, + x: offsetX - dragOffset.x, + y: offsetY - dragOffset.y, + })); + }; + + const onDragEnd = () => { + if (!movable) return; + + setIsMoving(false); + setDragOffset(null); + }; + + return { + isMoving, + onMouseOut, + onDragStart, + onDrag, + onDragEnd, + }; +}; + +export default useBoardMove; diff --git a/frontend/src/hooks/board/useBoardStatus.ts b/frontend/src/hooks/board/useBoardStatus.ts new file mode 100644 index 000000000..00209826b --- /dev/null +++ b/frontend/src/hooks/board/useBoardStatus.ts @@ -0,0 +1,33 @@ +import { Dispatch, SetStateAction, useEffect, useState } from 'react'; +import { EditorBoard } from 'types/common'; +import { BOARD } from '../../constants/editor'; + +interface Props { + width: number; + height: number; +} + +const useBoardStatus = ({ + width = BOARD.DEFAULT_WIDTH, + height = BOARD.DEFAULT_HEIGHT, +}: Props): [EditorBoard, Dispatch>] => { + const [boardStatus, setBoardStatus] = useState({ + scale: 1, + x: 0, + y: 0, + width, + height, + }); + + useEffect(() => { + setBoardStatus((prevStatus) => ({ + ...prevStatus, + width: Number(width), + height: Number(height), + })); + }, [width, height]); + + return [boardStatus, setBoardStatus]; +}; + +export default useBoardStatus; diff --git a/frontend/src/hooks/board/useBoardZoom.ts b/frontend/src/hooks/board/useBoardZoom.ts new file mode 100644 index 000000000..656f97971 --- /dev/null +++ b/frontend/src/hooks/board/useBoardZoom.ts @@ -0,0 +1,51 @@ +import { EDITOR } from 'constants/editor'; +import { EditorBoard } from 'types/common'; + +const useBoardZoom = ( + boardState: [EditorBoard, React.Dispatch>] +): { + onWheel: (event: React.WheelEvent) => void; +} => { + const zoomBoard = ({ offsetX, offsetY, deltaY }: WheelEvent) => { + const [, setBoard] = boardState; + + setBoard((prevStatus) => { + const { scale, x, y, width, height } = prevStatus; + + const nextScale = scale - deltaY * EDITOR.SCALE_DELTA; + + if (nextScale <= EDITOR.MIN_SCALE || nextScale >= EDITOR.MAX_SCALE) { + return { + ...prevStatus, + scale: prevStatus.scale, + }; + } + + const cursorX = (offsetX - x) / (width * scale); + const cursorY = (offsetY - y) / (height * scale); + + const widthDiff = Math.abs(width * nextScale - width * scale) * cursorX; + const heightDiff = Math.abs(height * nextScale - height * scale) * cursorY; + + const nextX = nextScale > scale ? x - widthDiff : x + widthDiff; + const nextY = nextScale > scale ? y - heightDiff : y + heightDiff; + + return { + ...prevStatus, + x: nextX, + y: nextY, + scale: nextScale, + }; + }); + }; + + const onWheel = (event: React.WheelEvent) => { + zoomBoard(event.nativeEvent); + }; + + return { + onWheel, + }; +}; + +export default useBoardZoom; diff --git a/frontend/src/hooks/query/useGithubLogin.ts b/frontend/src/hooks/query/useGithubLogin.ts new file mode 100644 index 000000000..ba670ab35 --- /dev/null +++ b/frontend/src/hooks/query/useGithubLogin.ts @@ -0,0 +1,21 @@ +import { AxiosError, AxiosResponse } from 'axios'; +import { QueryKey, useQuery, UseQueryOptions, UseQueryResult } from 'react-query'; +import { queryGithubLogin, SocialLoginParams } from 'api/login'; +import { LoginSuccess, SocialLoginFailure } from 'types/response'; + +const useGithubLogin = >( + { code }: SocialLoginParams, + options?: UseQueryOptions< + AxiosResponse, + AxiosError, + TData, + [QueryKey, SocialLoginParams] + > +): UseQueryResult> => + useQuery(['getGithubLogin', { code }], queryGithubLogin, { + ...options, + retry: false, + refetchOnWindowFocus: false, + }); + +export default useGithubLogin; diff --git a/frontend/src/hooks/query/useGoogleLogin.ts b/frontend/src/hooks/query/useGoogleLogin.ts new file mode 100644 index 000000000..836e6a45f --- /dev/null +++ b/frontend/src/hooks/query/useGoogleLogin.ts @@ -0,0 +1,21 @@ +import { AxiosError, AxiosResponse } from 'axios'; +import { QueryKey, useQuery, UseQueryOptions, UseQueryResult } from 'react-query'; +import { queryGoogleLogin, SocialLoginParams } from 'api/login'; +import { SocialLoginFailure, LoginSuccess } from 'types/response'; + +const useGoogleLogin = >( + { code }: SocialLoginParams, + options?: UseQueryOptions< + AxiosResponse, + AxiosError, + TData, + [QueryKey, SocialLoginParams] + > +): UseQueryResult> => + useQuery(['getGoogleLogin', { code }], queryGoogleLogin, { + ...options, + retry: false, + refetchOnWindowFocus: false, + }); + +export default useGoogleLogin; diff --git a/frontend/src/hooks/useGuestMap.ts b/frontend/src/hooks/query/useGuestMap.ts similarity index 100% rename from frontend/src/hooks/useGuestMap.ts rename to frontend/src/hooks/query/useGuestMap.ts diff --git a/frontend/src/hooks/useGuestReservations.ts b/frontend/src/hooks/query/useGuestReservations.ts similarity index 100% rename from frontend/src/hooks/useGuestReservations.ts rename to frontend/src/hooks/query/useGuestReservations.ts diff --git a/frontend/src/hooks/useGuestSpaces.ts b/frontend/src/hooks/query/useGuestSpaces.ts similarity index 100% rename from frontend/src/hooks/useGuestSpaces.ts rename to frontend/src/hooks/query/useGuestSpaces.ts diff --git a/frontend/src/hooks/useManagerMap.ts b/frontend/src/hooks/query/useManagerMap.ts similarity index 100% rename from frontend/src/hooks/useManagerMap.ts rename to frontend/src/hooks/query/useManagerMap.ts diff --git a/frontend/src/hooks/query/useManagerMapReservations.ts b/frontend/src/hooks/query/useManagerMapReservations.ts new file mode 100644 index 000000000..f720e5873 --- /dev/null +++ b/frontend/src/hooks/query/useManagerMapReservations.ts @@ -0,0 +1,17 @@ +import { AxiosError, AxiosResponse } from 'axios'; +import { QueryKey, useQuery, UseQueryOptions, UseQueryResult } from 'react-query'; +import { queryManagerMapReservations, QueryMapReservationsParams } from 'api/managerReservation'; +import { ErrorResponse, QueryManagerMapReservationsSuccess } from 'types/response'; + +const useManagerMapReservations = >( + { mapId, date }: QueryMapReservationsParams, + options?: UseQueryOptions< + AxiosResponse, + AxiosError, + TData, + [QueryKey, QueryMapReservationsParams] + > +): UseQueryResult> => + useQuery(['getManagerMapReservations', { mapId, date }], queryManagerMapReservations, options); + +export default useManagerMapReservations; diff --git a/frontend/src/hooks/useManagerMaps.ts b/frontend/src/hooks/query/useManagerMaps.ts similarity index 100% rename from frontend/src/hooks/useManagerMaps.ts rename to frontend/src/hooks/query/useManagerMaps.ts diff --git a/frontend/src/hooks/useManagerSpace.ts b/frontend/src/hooks/query/useManagerSpace.ts similarity index 100% rename from frontend/src/hooks/useManagerSpace.ts rename to frontend/src/hooks/query/useManagerSpace.ts diff --git a/frontend/src/hooks/query/useManagerSpaceReservations.ts b/frontend/src/hooks/query/useManagerSpaceReservations.ts new file mode 100644 index 000000000..1ee77cd59 --- /dev/null +++ b/frontend/src/hooks/query/useManagerSpaceReservations.ts @@ -0,0 +1,24 @@ +import { AxiosError, AxiosResponse } from 'axios'; +import { QueryKey, useQuery, UseQueryOptions, UseQueryResult } from 'react-query'; +import { + queryManagerSpaceReservations, + QueryManagerSpaceReservationsParams, +} from 'api/managerReservation'; +import { ErrorResponse, QueryManagerSpaceReservationsSuccess } from 'types/response'; + +const useManagerSpaceReservations = >( + { mapId, spaceId, date }: QueryManagerSpaceReservationsParams, + options?: UseQueryOptions< + AxiosResponse, + AxiosError, + TData, + [QueryKey, QueryManagerSpaceReservationsParams] + > +): UseQueryResult> => + useQuery( + ['getManagerSpaceReservations', { mapId, spaceId, date }], + queryManagerSpaceReservations, + options + ); + +export default useManagerSpaceReservations; diff --git a/frontend/src/hooks/useManagerSpaces.ts b/frontend/src/hooks/query/useManagerSpaces.ts similarity index 100% rename from frontend/src/hooks/useManagerSpaces.ts rename to frontend/src/hooks/query/useManagerSpaces.ts diff --git a/frontend/src/hooks/usePreset.ts b/frontend/src/hooks/query/usePreset.ts similarity index 100% rename from frontend/src/hooks/usePreset.ts rename to frontend/src/hooks/query/usePreset.ts diff --git a/frontend/src/hooks/useFormContext.ts b/frontend/src/hooks/useFormContext.ts new file mode 100644 index 000000000..a12e4c6fb --- /dev/null +++ b/frontend/src/hooks/useFormContext.ts @@ -0,0 +1,14 @@ +import React, { useContext } from 'react'; +import THROW_ERROR from 'constants/throwError'; + +const useFormContext = (context: React.Context): T => { + const contextData = useContext(context); + + if (contextData === null) { + throw new Error(THROW_ERROR.NOT_EXIST_CONTEXT); + } + + return contextData; +}; + +export default useFormContext; diff --git a/frontend/src/hooks/useInputs.ts b/frontend/src/hooks/useInputs.ts index 4e49c3398..75193a694 100644 --- a/frontend/src/hooks/useInputs.ts +++ b/frontend/src/hooks/useInputs.ts @@ -1,6 +1,6 @@ import React, { useState } from 'react'; -const useInputs = >( +const useInputs = >( initialValues: T ): [ T, @@ -9,10 +9,18 @@ const useInputs = >( ] => { const [values, setValues] = useState(initialValues); + const getValue = (event: React.ChangeEvent) => { + if (event.target.type === 'checkbox') { + return event.target.checked; + } + + return event.target.value; + }; + const handleChange = (event: React.ChangeEvent) => { setValues((prevValues) => ({ ...prevValues, - [event.target.name]: event.target.value, + [event.target.name]: getValue(event), })); }; diff --git a/frontend/src/hooks/useManagerReservations.ts b/frontend/src/hooks/useManagerReservations.ts deleted file mode 100644 index 46fd757c4..000000000 --- a/frontend/src/hooks/useManagerReservations.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { AxiosError, AxiosResponse } from 'axios'; -import { QueryKey, useQuery, UseQueryOptions, UseQueryResult } from 'react-query'; -import { queryManagerReservations, QueryMapReservationsParams } from 'api/managerReservation'; -import { ErrorResponse, QueryManagerReservationsSuccess } from 'types/response'; - -const useManagerReservations = >( - { mapId, date }: QueryMapReservationsParams, - options?: UseQueryOptions< - AxiosResponse, - AxiosError, - TData, - [QueryKey, QueryMapReservationsParams] - > -): UseQueryResult> => - useQuery(['getManagerReservations', { mapId, date }], queryManagerReservations, options); - -export default useManagerReservations; diff --git a/frontend/src/hooks/useQueryString.ts b/frontend/src/hooks/useQueryString.ts new file mode 100644 index 000000000..6151c3236 --- /dev/null +++ b/frontend/src/hooks/useQueryString.ts @@ -0,0 +1,5 @@ +import { useLocation } from 'react-router-dom'; + +const useQueryString = (): URLSearchParams => new URLSearchParams(useLocation().search); + +export default useQueryString; diff --git a/frontend/src/hooks/useScrollToTop.ts b/frontend/src/hooks/useScrollToTop.ts new file mode 100644 index 000000000..22f0f7f43 --- /dev/null +++ b/frontend/src/hooks/useScrollToTop.ts @@ -0,0 +1,9 @@ +import { useEffect } from 'react'; + +const useScrollToTop = (): void => { + useEffect(() => { + window.scrollTo(0, 0); + }, []); +}; + +export default useScrollToTop; diff --git a/frontend/src/pages/GuestMap/GuestMap.styles.ts b/frontend/src/pages/GuestMap/GuestMap.styles.ts index 6b17f2a13..062eb97f4 100644 --- a/frontend/src/pages/GuestMap/GuestMap.styles.ts +++ b/frontend/src/pages/GuestMap/GuestMap.styles.ts @@ -1,10 +1,4 @@ -import { Link } from 'react-router-dom'; import styled from 'styled-components'; -import { Color } from 'types/common'; - -interface ColorDotProps { - color: Color; -} export const Page = styled.div` display: flex; @@ -22,15 +16,6 @@ export const PageTitle = styled.h2` margin: 1.5rem auto; `; -export const SpaceTitle = styled.h3` - position: sticky; - top: 0; - font-size: 1.25rem; - text-align: center; - padding: 2rem; - background-color: ${({ theme }) => theme.white}; -`; - export const MapContainer = styled.div` width: 100%; flex: 1; @@ -74,37 +59,6 @@ export const SpaceAreaText = styled.text` user-select: none; `; -export const ReservationContainer = styled.div` - padding: 0 2rem 2rem; -`; - -export const ReservationLink = styled(Link)` - position: sticky; - bottom: 0; - left: 0; - width: 100%; - text-align: center; - display: block; - background: ${({ theme }) => theme.primary[400]}; - color: ${({ theme }) => theme.white}; - text-decoration: none; - padding: 1rem; - font-size: 1.25rem; - font-weight: 700; - cursor: pointer; -`; - -export const ReservationList = styled.div` - overflow-y: auto; - & > [role='listitem'] { - border-bottom: 1px solid ${({ theme }) => theme.black[400]}; - - &:last-of-type { - border: 0; - } - } -`; - export const SelectBox = styled.div` display: flex; flex-direction: column; @@ -140,21 +94,3 @@ export const DeleteModalContainer = styled.div` justify-content: flex-end; margin-top: 1.5rem; `; - -export const Message = styled.p` - padding: 1rem 0; -`; - -export const ColorDot = styled.span` - display: inline-block; - width: 1rem; - height: 1rem; - background-color: ${({ color }) => color}; - border-radius: 50%; - margin-right: 0.75rem; -`; - -export const IconButtonWrapper = styled.div` - display: flex; - gap: 0.5rem; -`; diff --git a/frontend/src/pages/GuestMap/GuestMap.tsx b/frontend/src/pages/GuestMap/GuestMap.tsx index 9580e7e11..439f58950 100644 --- a/frontend/src/pages/GuestMap/GuestMap.tsx +++ b/frontend/src/pages/GuestMap/GuestMap.tsx @@ -3,28 +3,24 @@ import { FormEventHandler, useEffect, useMemo, useRef, useState } from 'react'; import { useMutation } from 'react-query'; import { useHistory, useLocation, useParams } from 'react-router-dom'; import { deleteGuestReservation } from 'api/guestReservation'; -import { ReactComponent as DeleteIcon } from 'assets/svg/delete.svg'; -import { ReactComponent as EditIcon } from 'assets/svg/edit.svg'; import Button from 'components/Button/Button'; import DateInput from 'components/DateInput/DateInput'; -import Drawer from 'components/Drawer/Drawer'; import Header from 'components/Header/Header'; -import IconButton from 'components/IconButton/IconButton'; import Input from 'components/Input/Input'; import Layout from 'components/Layout/Layout'; import Modal from 'components/Modal/Modal'; -import ReservationListItem from 'components/ReservationListItem/ReservationListItem'; import { EDITOR } from 'constants/editor'; import MESSAGE from 'constants/message'; import PALETTE from 'constants/palette'; -import useGuestMap from 'hooks/useGuestMap'; -import useGuestReservations from 'hooks/useGuestReservations'; -import useGuestSpaces from 'hooks/useGuestSpaces'; +import PATH, { HREF } from 'constants/path'; +import useGuestMap from 'hooks/query/useGuestMap'; +import useGuestSpaces from 'hooks/query/useGuestSpaces'; import useInput from 'hooks/useInput'; import { Area, MapDrawing, MapItem, Reservation, ScrollPosition, Space } from 'types/common'; import { ErrorResponse } from 'types/response'; import { formatDate } from 'utils/datetime'; import * as Styled from './GuestMap.styles'; +import ReservationDrawer from './units/ReservationDrawer'; export interface GuestMapState { spaceId?: Space['id']; @@ -38,7 +34,6 @@ export interface URLParameter { const GuestMap = (): JSX.Element => { const [detailOpen, setDetailOpen] = useState(false); - const [modalOpen, setModalOpen] = useState(false); const [passwordInputModalOpen, setPasswordInputModalOpen] = useState(false); const [selectedReservation, setSelectedReservation] = useState(); @@ -54,24 +49,25 @@ const GuestMap = (): JSX.Element => { const targetDate = location.state?.targetDate; const scrollPosition = location.state?.scrollPosition; - const now = new Date(); - const todayDate = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - const [map, setMap] = useState(null); const mapDrawing = map?.mapDrawing; const getMap = useGuestMap( { sharingMapId }, { onError: () => { - history.push('/not-found'); + history.push(PATH.NOT_FOUND[1]); }, onSuccess: (response) => { const mapData = response.data; - setMap({ - ...mapData, - mapDrawing: JSON.parse(mapData.mapDrawing) as MapDrawing, - }); + try { + setMap({ + ...mapData, + mapDrawing: JSON.parse(mapData.mapDrawing) as MapDrawing, + }); + } catch (error) { + alert(MESSAGE.GUEST_MAP.MAP_DRAWING_PARSE_ERROR); + } }, retry: false, } @@ -104,23 +100,9 @@ const GuestMap = (): JSX.Element => { const [date, setDate] = useState(targetDate ? new Date(targetDate) : new Date()); - const getReservations = useGuestReservations( - { - mapId: map?.mapId as number, - spaceId: Number(selectedSpaceId), - date: formatDate(date), - }, - { - enabled: map?.mapId !== undefined && selectedSpaceId !== null, - } - ); - const reservations = getReservations.data?.data?.reservations ?? []; - const reservationAvailable = new Date(date) > todayDate; - const removeReservation = useMutation(deleteGuestReservation, { onSuccess: () => { window.alert(MESSAGE.RESERVATION.DELETE_SUCCESS); - setModalOpen(false); setPasswordInputModalOpen(false); }, @@ -134,7 +116,7 @@ const GuestMap = (): JSX.Element => { setDetailOpen(true); }; - const handleSelectEdit = (reservation: Reservation) => { + const handleEdit = (reservation: Reservation) => { if (!selectedSpaceId) return; history.push({ @@ -144,11 +126,12 @@ const GuestMap = (): JSX.Element => { space: spaces[selectedSpaceId], reservation, selectedDate: formatDate(date), + scrollPosition: { x: mapRef?.current?.scrollLeft, y: mapRef?.current?.scrollTop }, }, }); }; - const handleSelectDelete = (reservation: Reservation): void => { + const handleDelete = (reservation: Reservation) => { setPasswordInputModalOpen(true); setSelectedReservation(reservation); }; @@ -166,6 +149,20 @@ const GuestMap = (): JSX.Element => { }); }; + const handleReservation = () => { + if (!selectedSpaceId) return; + + history.push({ + pathname: HREF.GUEST_RESERVATION(sharingMapId), + state: { + mapId: map?.mapId, + space: spaces[selectedSpaceId], + selectedDate: formatDate(date), + scrollPosition: { x: mapRef?.current?.scrollLeft, y: mapRef?.current?.scrollTop }, + }, + }); + }; + useEffect(() => { if (scrollPosition) { mapRef?.current?.scrollTo(scrollPosition.x ?? 0, scrollPosition.y ?? 0); @@ -250,103 +247,52 @@ const GuestMap = (): JSX.Element => { - setDetailOpen(false)}> - {spaceList.length > 0 && selectedSpaceId !== null && ( - <> - - - {spaces[selectedSpaceId].name} - - - {getReservations.isLoadingError && ( - - 예약 목록을 불러오는 데 문제가 생겼어요! -
- 새로 고침으로 다시 시도해주세요. -
- )} - {getReservations.isLoading && !getReservations.isLoadingError && ( - 불러오는 중입니다... - )} - {getReservations.isSuccess && reservations?.length === 0 && ( - 오늘의 첫 예약을 잡아보세요! - )} - {getReservations.isSuccess && reservations.length > 0 && ( - - {reservations.map((reservation: Reservation) => ( - - handleSelectEdit(reservation)} - aria-label="수정" - > - - - handleSelectDelete(reservation)} - aria-label="삭제" - > - - - - } - /> - ))} - - )} -
- - )} - {spaceList.length > 0 && selectedSpaceId !== null && reservationAvailable && ( - - 예약하기 - - )} -
- - setModalOpen(false)} - > - 예약시 사용하신 비밀번호를 입력해주세요. - -
- - - - - -
-
-
+ {selectedSpaceId && map?.mapId && detailOpen && ( + setDetailOpen(false)} + onClickReservation={handleReservation} + onEdit={handleEdit} + onDelete={handleDelete} + /> + )} + + {passwordInputModalOpen && ( + setPasswordInputModalOpen(false)} + > + 예약시 사용하신 비밀번호를 입력해주세요. + +
+ + + + + +
+
+
+ )} ); }; diff --git a/frontend/src/pages/GuestMap/units/ReservationDrawer.styles.ts b/frontend/src/pages/GuestMap/units/ReservationDrawer.styles.ts new file mode 100644 index 000000000..0688ca9b7 --- /dev/null +++ b/frontend/src/pages/GuestMap/units/ReservationDrawer.styles.ts @@ -0,0 +1,46 @@ +import styled from 'styled-components'; +import Button from 'components/Button/Button'; +import ColorDotComponent from 'components/ColorDot/ColorDot'; + +export const ReservationContainer = styled.div` + padding: 0 2rem 2rem; +`; + +export const ReservationList = styled.div` + overflow-y: auto; + & > [role='listitem'] { + border-bottom: 1px solid ${({ theme }) => theme.black[400]}; + + &:last-of-type { + border: 0; + } + } +`; + +export const ReservationButton = styled(Button)` + position: sticky; + bottom: 0; + left: 0; +`; + +export const SpaceTitle = styled.h3` + position: sticky; + top: 0; + font-size: 1.25rem; + text-align: center; + padding: 2rem; + background-color: ${({ theme }) => theme.white}; +`; + +export const ColorDot = styled(ColorDotComponent)` + margin-right: 0.75rem; +`; + +export const Message = styled.p` + padding: 1rem 0; +`; + +export const IconButtonWrapper = styled.div` + display: flex; + gap: 0.5rem; +`; diff --git a/frontend/src/pages/GuestMap/units/ReservationDrawer.tsx b/frontend/src/pages/GuestMap/units/ReservationDrawer.tsx new file mode 100644 index 000000000..ddb789ece --- /dev/null +++ b/frontend/src/pages/GuestMap/units/ReservationDrawer.tsx @@ -0,0 +1,95 @@ +import { ReactComponent as DeleteIcon } from 'assets/svg/delete.svg'; +import { ReactComponent as EditIcon } from 'assets/svg/edit.svg'; +import Drawer from 'components/Drawer/Drawer'; +import IconButton from 'components/IconButton/IconButton'; +import ReservationListItem from 'components/ReservationListItem/ReservationListItem'; +import useGuestReservations from 'hooks/query/useGuestReservations'; +import { Reservation, Space } from 'types/common'; +import { formatDate } from 'utils/datetime'; +import * as Styled from './ReservationDrawer.styles'; + +interface Props { + mapId: number; + space: Space; + date: Date; + open: boolean; + onClose: () => void; + onClickReservation: () => void; + onEdit: (reservation: Reservation) => void; + onDelete: (reservation: Reservation) => void; +} + +const ReservationDrawer = ({ + mapId, + space, + date, + open, + onClose, + onClickReservation, + onEdit, + onDelete, +}: Props): JSX.Element => { + const getReservations = useGuestReservations({ + mapId, + spaceId: space.id, + date: formatDate(date), + }); + + const reservations = getReservations.data?.data?.reservations ?? []; + + return ( + + + + {space.name} + + + {getReservations.isLoadingError && ( + + 예약 목록을 불러오는 데 문제가 생겼어요! +
+ 새로 고침으로 다시 시도해주세요. +
+ )} + {getReservations.isSuccess && reservations?.length === 0 && ( + 오늘의 첫 예약을 잡아보세요! + )} + {getReservations.isSuccess && reservations.length > 0 && ( + + {reservations.map((reservation: Reservation) => ( + + onEdit(reservation)} aria-label="수정"> + + + onDelete(reservation)} + aria-label="삭제" + > + + + + } + /> + ))} + + )} +
+ + 예약하기 + +
+ ); +}; + +export default ReservationDrawer; diff --git a/frontend/src/pages/GuestReservation/GuestReservation.styles.ts b/frontend/src/pages/GuestReservation/GuestReservation.styles.ts index d8d28d55e..9c5770e0e 100644 --- a/frontend/src/pages/GuestReservation/GuestReservation.styles.ts +++ b/frontend/src/pages/GuestReservation/GuestReservation.styles.ts @@ -1,16 +1,16 @@ import styled from 'styled-components'; -import { Color } from 'types/common'; +import ColorDotComponent from 'components/ColorDot/ColorDot'; -interface ColorDotProps { - color: Color; -} +export const Section = styled.section` + margin: 1.5rem 0 4.5rem; +`; -export const ReservationForm = styled.form` - margin: 1.5rem 0 5rem 0; +export const Message = styled.p` + white-space: pre-wrap; `; -export const Section = styled.section` - margin: 1.5rem 0; +export const ReservationList = styled.div` + border-top: 1px solid ${({ theme }) => theme.gray[400]}; `; export const PageHeader = styled.h2` @@ -21,44 +21,6 @@ export const PageHeader = styled.h2` align-items: center; `; -export const InputWrapper = styled.div` - position: relative; - display: flex; - gap: 1rem; - margin: 1.625rem 0; - - label { - flex: 1; - } -`; - -export const ReservationList = styled.div` - border-top: 1px solid ${({ theme }) => theme.gray[400]}; -`; - -export const ButtonWrapper = styled.div` - position: fixed; - bottom: 0; - left: 0; - width: 100vw; -`; - -export const TimeFormMessage = styled.p` - position: absolute; - left: 0.75rem; - bottom: -1rem; - font-size: 0.75rem; - height: 1em; - color: ${({ theme }) => theme.gray[500]}; -`; - -export const Message = styled.p``; - -export const ColorDot = styled.span` - display: inline-block; - width: 1rem; - height: 1rem; - background-color: ${({ color }) => color}; - border-radius: 50%; +export const ColorDot = styled(ColorDotComponent)` margin-right: 0.75rem; `; diff --git a/frontend/src/pages/GuestReservation/GuestReservation.tsx b/frontend/src/pages/GuestReservation/GuestReservation.tsx index 6335bf752..ebc486d84 100644 --- a/frontend/src/pages/GuestReservation/GuestReservation.tsx +++ b/frontend/src/pages/GuestReservation/GuestReservation.tsx @@ -1,35 +1,36 @@ import { AxiosError } from 'axios'; -import { FormEventHandler, useEffect } from 'react'; +import React, { useEffect } from 'react'; import { useMutation } from 'react-query'; import { useHistory, useLocation, useParams } from 'react-router-dom'; -import { postGuestReservation } from 'api/guestReservation'; -import { ReactComponent as CalendarIcon } from 'assets/svg/calendar.svg'; -import Button from 'components/Button/Button'; +import { postGuestReservation, putGuestReservation, ReservationParams } from 'api/guestReservation'; import Header from 'components/Header/Header'; -import Input from 'components/Input/Input'; import Layout from 'components/Layout/Layout'; import PageHeader from 'components/PageHeader/PageHeader'; import ReservationListItem from 'components/ReservationListItem/ReservationListItem'; import MESSAGE from 'constants/message'; -import REGEXP from 'constants/regexp'; -import RESERVATION from 'constants/reservation'; -import useGuestReservations from 'hooks/useGuestReservations'; +import { HREF } from 'constants/path'; +import useGuestReservations from 'hooks/query/useGuestReservations'; import useInput from 'hooks/useInput'; import { GuestMapState } from 'pages/GuestMap/GuestMap'; -import { MapItem, ScrollPosition, Space } from 'types/common'; +import { MapItem, Reservation, ScrollPosition, Space } from 'types/common'; import { ErrorResponse } from 'types/response'; -import { formatDate, formatTime, formatTimePrettier } from 'utils/datetime'; import * as Styled from './GuestReservation.styles'; +import GuestReservationForm from './units/GuestReservationForm'; + +interface URLParameter { + sharingMapId: MapItem['sharingMapId']; +} interface GuestReservationState { mapId: number; space: Space; selectedDate: string; scrollPosition: ScrollPosition; + reservation?: Reservation; } -interface URLParameter { - sharingMapId: MapItem['sharingMapId']; +export interface EditReservationParams extends ReservationParams { + reservationId?: number; } const GuestReservation = (): JSX.Element => { @@ -37,40 +38,25 @@ const GuestReservation = (): JSX.Element => { const history = useHistory(); const { sharingMapId } = useParams(); - const { mapId, space, selectedDate, scrollPosition } = location.state; - const { availableStartTime, availableEndTime, reservationTimeUnit, reservationMaximumTimeUnit } = - space.settings; + const { mapId, space, selectedDate, scrollPosition, reservation } = location.state; - if (!mapId || !space) history.replace(`/guest/${sharingMapId}`); + if (!mapId || !space) history.replace(HREF.GUEST_MAP(sharingMapId)); - const now = new Date(); - const todayDate = formatDate(new Date()); - - const initialStartTime = formatTime(now); - const initialEndTime = formatTime( - new Date(new Date().getTime() + 1000 * 60 * reservationTimeUnit) - ); - const availableStartTimeText = formatTime(new Date(`${todayDate}T${availableStartTime}`)); - const availableEndTimeText = formatTime(new Date(`${todayDate}T${availableEndTime}`)); - - const [name, onChangeName] = useInput(''); - const [description, onChangeDescription] = useInput(''); const [date, onChangeDate] = useInput(selectedDate); - const [startTime, onChangeStartTime] = useInput(initialStartTime); - const [endTime, onChangeEndTime] = useInput(initialEndTime); - const [password, onChangePassword] = useInput(''); - const startDateTime = new Date(`${date}T${startTime}Z`); - const endDateTime = new Date(`${date}T${endTime}Z`); + const isEditMode = !!reservation; const getReservations = useGuestReservations({ mapId, spaceId: space.id, date }); const reservations = getReservations.data?.data?.reservations ?? []; - const createReservation = useMutation(postGuestReservation, { + const addReservation = useMutation(postGuestReservation, { onSuccess: () => { - history.push(`/guest/${sharingMapId}`, { - spaceId: space.id, - targetDate: new Date(`${date}T${startTime}`), + history.push({ + pathname: HREF.GUEST_MAP(sharingMapId), + state: { + spaceId: space.id, + targetDate: new Date(date), + }, }); }, onError: (error: AxiosError) => { @@ -78,149 +64,104 @@ const GuestReservation = (): JSX.Element => { }, }); - const handleSubmit: FormEventHandler = (event) => { - event.preventDefault(); + const updateReservation = useMutation(putGuestReservation, { + onSuccess: () => { + history.push({ + pathname: HREF.GUEST_MAP(sharingMapId), + state: { + spaceId: space.id, + targetDate: new Date(date), + }, + }); + }, - if (createReservation.isLoading) return; + onError: (error: AxiosError) => { + alert(error.response?.data.message ?? MESSAGE.RESERVATION.UNEXPECTED_ERROR); + }, + }); - const reservation = { name, description, password, startDateTime, endDateTime }; + const createReservation = ({ reservation }: ReservationParams) => { + if (addReservation.isLoading) return; - createReservation.mutate({ reservation, mapId, spaceId: space.id }); + addReservation.mutate({ + reservation, + mapId, + spaceId: space.id, + }); + }; + + const editReservation = ({ reservation, reservationId }: EditReservationParams) => { + if (updateReservation.isLoading || !isEditMode || !reservationId) return; + + updateReservation.mutate({ + reservation, + mapId, + spaceId: space.id, + reservationId, + }); + }; + + const handleSubmit = ( + event: React.FormEvent, + { reservation, reservationId }: EditReservationParams + ) => { + event.preventDefault(); + + reservationId + ? editReservation({ reservation, reservationId }) + : createReservation({ reservation }); }; useEffect(() => { return history.listen((location) => { if ( - location.pathname === `/guest/${sharingMapId}` || - location.pathname === `/guest/${sharingMapId}/` + location.pathname === HREF.GUEST_MAP(sharingMapId) || + location.pathname === HREF.GUEST_MAP(sharingMapId) + '/' ) { location.state = { spaceId: space.id, - targetDate: new Date(selectedDate), + targetDate: new Date(date), scrollPosition, }; } }); - }, [history, scrollPosition, selectedDate, space, sharingMapId]); - - useEffect(() => { - window.scrollTo(0, 0); - }, []); + }, [history, scrollPosition, date, space, sharingMapId]); return ( <>
- - - - - {space.name} - - - - - - - - - } - value={date} - min={formatDate(now)} - onChange={onChangeDate} - required - /> - - - - - - 예약 가능 시간 : {availableStartTimeText} ~ {availableEndTimeText} (최대{' '} - {formatTimePrettier(reservationMaximumTimeUnit)}) - - - - - - - - - {getReservations.isLoadingError && ( - - 예약 목록을 불러오는 데 문제가 생겼어요! -
- 새로 고침으로 다시 시도해주세요. -
- )} - {getReservations.isLoading && !getReservations.isLoadingError && ( - 불러오는 중입니다... - )} - {getReservations.isSuccess && reservations.length === 0 && ( - 오늘의 첫 예약을 잡아보세요! - )} - {getReservations.isSuccess && reservations.length > 0 && ( - - {reservations?.map((reservation) => ( - - ))} - - )} -
- - - - -
+ + + {space.name} + + + + + {getReservations.isLoadingError && ( + {MESSAGE.RESERVATION.ERROR} + )} + {getReservations.isLoading && !getReservations.isLoadingError && ( + {MESSAGE.RESERVATION.PENDING} + )} + {getReservations.isSuccess && reservations.length === 0 && ( + {MESSAGE.RESERVATION.SUGGESTION} + )} + {getReservations.isSuccess && reservations.length > 0 && ( + + {reservations?.map((reservation) => ( + + ))} + + )} +
); diff --git a/frontend/src/pages/GuestReservationEdit/GuestReservationEdit.styles.ts b/frontend/src/pages/GuestReservation/units/GuestReservationForm.styles.ts similarity index 51% rename from frontend/src/pages/GuestReservationEdit/GuestReservationEdit.styles.ts rename to frontend/src/pages/GuestReservation/units/GuestReservationForm.styles.ts index 1ba1cfc8d..5723fe860 100644 --- a/frontend/src/pages/GuestReservationEdit/GuestReservationEdit.styles.ts +++ b/frontend/src/pages/GuestReservation/units/GuestReservationForm.styles.ts @@ -1,18 +1,7 @@ import styled from 'styled-components'; -import { Color } from 'types/common'; - -interface ColorDotProps { - color: Color; -} export const ReservationForm = styled.form` - margin: 1.5rem 0 5rem 0; -`; - -export const PageHeader = styled.h2` - font-size: 1.625rem; - font-weight: 700; - margin: 1.5rem 0; + margin: 1.5rem 0 0; `; export const Section = styled.section` @@ -30,10 +19,6 @@ export const InputWrapper = styled.div` } `; -export const ReservationList = styled.div` - border-top: 1px solid ${({ theme }) => theme.gray[400]}; -`; - export const ButtonWrapper = styled.div` position: fixed; bottom: 0; @@ -49,14 +34,3 @@ export const TimeFormMessage = styled.p` height: 1em; color: ${({ theme }) => theme.gray[500]}; `; - -export const Message = styled.p``; - -export const ColorDot = styled.span` - display: inline-block; - width: 1rem; - height: 1rem; - background-color: ${({ color }) => color}; - border-radius: 50%; - margin-right: 0.75rem; -`; diff --git a/frontend/src/pages/GuestReservation/units/GuestReservationForm.tsx b/frontend/src/pages/GuestReservation/units/GuestReservationForm.tsx new file mode 100644 index 000000000..9bcdc8d76 --- /dev/null +++ b/frontend/src/pages/GuestReservation/units/GuestReservationForm.tsx @@ -0,0 +1,189 @@ +import { ChangeEventHandler } from 'react'; +import { ReactComponent as CalendarIcon } from 'assets/svg/calendar.svg'; +import Button from 'components/Button/Button'; +import Input from 'components/Input/Input'; +import MESSAGE from 'constants/message'; +import REGEXP from 'constants/regexp'; +import RESERVATION from 'constants/reservation'; +import TIME from 'constants/time'; +import useInputs from 'hooks/useInputs'; +import useScrollToTop from 'hooks/useScrollToTop'; +import { Reservation, Space } from 'types/common'; +import { formatDate, formatTime, formatTimePrettier } from 'utils/datetime'; +import { EditReservationParams } from '../GuestReservation'; +import * as Styled from './GuestReservationForm.styles'; + +interface Props { + isEditMode: boolean; + space: Space; + reservation?: Reservation; + date: string; + onChangeDate: ChangeEventHandler; + onSubmit: ( + event: React.FormEvent, + { reservation, reservationId }: EditReservationParams + ) => void; +} + +interface Form { + name: string; + description: string; + startTime: string; + endTime: string; + password: string; +} + +const GuestReservationForm = ({ + isEditMode, + space, + date, + reservation, + onSubmit, + onChangeDate, +}: Props): JSX.Element => { + useScrollToTop(); + + const { availableStartTime, availableEndTime, reservationTimeUnit, reservationMaximumTimeUnit } = + space.settings; + + const now = new Date(); + const todayDate = formatDate(new Date()); + + const getInitialStartTime = () => { + if (isEditMode && reservation) { + return formatTime(new Date(reservation.startDateTime)); + } + + return formatTime(now); + }; + + const getInitialEndTime = () => { + if (isEditMode && reservation) { + return formatTime(new Date(reservation.endDateTime)); + } + + return formatTime( + new Date(new Date().getTime() + TIME.MILLISECONDS_PER_MINUTE * reservationTimeUnit) + ); + }; + + const initialStartTime = getInitialStartTime(); + const initialEndTime = getInitialEndTime(); + + const availableStartTimeText = formatTime(new Date(`${todayDate}T${availableStartTime}`)); + const availableEndTimeText = formatTime(new Date(`${todayDate}T${availableEndTime}`)); + + const [{ name, description, startTime, endTime, password }, onChangeForm] = useInputs
({ + name: reservation?.name ?? '', + description: reservation?.description ?? '', + startTime: initialStartTime, + endTime: initialEndTime, + password: '', + }); + + const startDateTime = new Date(`${date}T${startTime}Z`); + const endDateTime = new Date(`${date}T${endTime}Z`); + + return ( + + onSubmit(event, { + reservation: { + startDateTime, + endDateTime, + password, + name, + description, + }, + reservationId: reservation?.id, + }) + } + > + + + + + + + + + } + value={date} + min={formatDate(now)} + onChange={onChangeDate} + required + /> + + + + + + 예약 가능 시간 : {availableStartTimeText} ~ {availableEndTimeText} (최대{' '} + {formatTimePrettier(reservationMaximumTimeUnit)}) + + + + + + + + + + + ); +}; + +export default GuestReservationForm; diff --git a/frontend/src/pages/GuestReservationEdit/GuestReservationEdit.tsx b/frontend/src/pages/GuestReservationEdit/GuestReservationEdit.tsx deleted file mode 100644 index 3aa7dbcfa..000000000 --- a/frontend/src/pages/GuestReservationEdit/GuestReservationEdit.tsx +++ /dev/null @@ -1,217 +0,0 @@ -import { AxiosError } from 'axios'; -import { FormEventHandler } from 'react'; -import { useMutation } from 'react-query'; -import { useHistory, useLocation, useParams } from 'react-router-dom'; -import { putGuestReservation } from 'api/guestReservation'; -import { ReactComponent as CalendarIcon } from 'assets/svg/calendar.svg'; -import Button from 'components/Button/Button'; -import Header from 'components/Header/Header'; -import Input from 'components/Input/Input'; -import Layout from 'components/Layout/Layout'; -import PageHeader from 'components/PageHeader/PageHeader'; -import ReservationListItem from 'components/ReservationListItem/ReservationListItem'; -import MESSAGE from 'constants/message'; -import REGEXP from 'constants/regexp'; -import RESERVATION from 'constants/reservation'; -import useGuestReservations from 'hooks/useGuestReservations'; -import useInput from 'hooks/useInput'; -import { GuestMapState } from 'pages/GuestMap/GuestMap'; -import { MapItem, Reservation, Space } from 'types/common'; -import { ErrorResponse } from 'types/response'; -import { formatDate, formatTime, formatTimePrettier } from 'utils/datetime'; -import * as Styled from './GuestReservationEdit.styles'; - -interface GuestReservationEditState { - mapId: number; - space: Space; - reservation: Reservation; - selectedDate: string; -} - -interface URLParameter { - sharingMapId: MapItem['sharingMapId']; -} - -const GuestReservationEdit = (): JSX.Element => { - const location = useLocation(); - const history = useHistory(); - const { sharingMapId } = useParams(); - - const { mapId, space, reservation, selectedDate } = location.state; - const { availableStartTime, availableEndTime, reservationTimeUnit, reservationMaximumTimeUnit } = - space.settings; - - if (!mapId || !space || !reservation) history.replace(`/guest/${sharingMapId}`); - - const now = new Date(); - const todayDate = formatDate(new Date()); - - const [name, onChangeName] = useInput(reservation.name); - const [description, onChangeDescription] = useInput(reservation.description); - const [date, onChangeDate] = useInput(selectedDate); - const [startTime, onChangeStartTime] = useInput(formatTime(new Date(reservation.startDateTime))); - const [endTime, onChangeEndTime] = useInput(formatTime(new Date(reservation.endDateTime))); - const [password, onChangePassword] = useInput(''); - - const startDateTime = new Date(`${date}T${startTime}Z`); - const endDateTime = new Date(`${date}T${endTime}Z`); - - const availableStartTimeText = formatTime(new Date(`${todayDate}T${availableStartTime}`)); - const availableEndTimeText = formatTime(new Date(`${todayDate}T${availableEndTime}`)); - - const getReservations = useGuestReservations({ mapId, spaceId: space.id, date }); - const reservations = getReservations.data?.data?.reservations ?? []; - - const editReservation = useMutation(putGuestReservation, { - onSuccess: () => { - history.push(`/guest/${sharingMapId}`, { - spaceId: space.id, - targetDate: new Date(`${date}T${startTime}`), - }); - }, - - onError: (error: AxiosError) => { - alert(error.response?.data.message ?? MESSAGE.RESERVATION.UNEXPECTED_ERROR); - }, - }); - - const handleSubmit: FormEventHandler = (event) => { - event.preventDefault(); - - if (editReservation.isLoading) return; - - const editReservationParams = { - name, - description, - password, - startDateTime, - endDateTime, - }; - - editReservation.mutate({ - reservation: editReservationParams, - mapId, - spaceId: space.id, - reservationId: reservation.id, - }); - }; - - return ( - <> -
- - - - - - {space.name} - - - - - - - - - } - value={date} - min={formatDate(now)} - onChange={onChangeDate} - required - /> - - - - - - 예약 가능 시간 : {availableStartTimeText} ~ {availableEndTimeText} (최대{' '} - {formatTimePrettier(reservationMaximumTimeUnit)}) - - - - - - - - - {getReservations.isLoadingError && ( - - 예약 목록을 불러오는 데 문제가 생겼어요! -
- 새로 고침으로 다시 시도해주세요. -
- )} - {getReservations.isLoading && !getReservations.isLoadingError && ( - 불러오는 중입니다... - )} - {getReservations.isSuccess && reservations.length > 0 && ( - - {reservations?.map((reservation) => ( - - ))} - - )} -
- - - -
-
- - ); -}; - -export default GuestReservationEdit; diff --git a/frontend/src/pages/ManagerJoin/ManagerJoin.styles.ts b/frontend/src/pages/ManagerJoin/ManagerJoin.styles.ts index ec42bfda0..cf1366e59 100644 --- a/frontend/src/pages/ManagerJoin/ManagerJoin.styles.ts +++ b/frontend/src/pages/ManagerJoin/ManagerJoin.styles.ts @@ -13,14 +13,6 @@ export const PageTitle = styled.h2` margin: 2.125rem auto; `; -export const Form = styled.form` - margin: 3.75rem 0; - - label { - margin-bottom: 3rem; - } -`; - export const JoinLinkMessage = styled.p` margin: 1rem 0; text-align: center; diff --git a/frontend/src/pages/ManagerJoin/ManagerJoin.tsx b/frontend/src/pages/ManagerJoin/ManagerJoin.tsx index 72e2dd39e..9e2671da3 100644 --- a/frontend/src/pages/ManagerJoin/ManagerJoin.tsx +++ b/frontend/src/pages/ManagerJoin/ManagerJoin.tsx @@ -1,49 +1,26 @@ import { AxiosError } from 'axios'; -import { FormEventHandler, useEffect, useState } from 'react'; -import { useMutation, useQuery } from 'react-query'; -import { Link, useHistory } from 'react-router-dom'; -import { postJoin, queryValidateEmail } from 'api/join'; -import Button from 'components/Button/Button'; +import React from 'react'; +import { useMutation } from 'react-query'; +import { useHistory } from 'react-router'; +import { Link } from 'react-router-dom'; +import { postJoin } from 'api/join'; import Header from 'components/Header/Header'; -import Input from 'components/Input/Input'; import Layout from 'components/Layout/Layout'; -import MANAGER from 'constants/manager'; import MESSAGE from 'constants/message'; import PATH from 'constants/path'; -import REGEXP from 'constants/regexp'; -import useInput from 'hooks/useInput'; import { ErrorResponse } from 'types/response'; import * as Styled from './ManagerJoin.styles'; +import JoinForm from './units/JoinForm'; -const ManagerJoin = (): JSX.Element => { - const [email, onChangeEmail] = useInput(''); - const [password, onChangePassword] = useInput(''); - const [passwordConfirm, onChangePasswordConfirm] = useInput(''); - const [organization, onChangeOrganization] = useInput(''); - - const [emailMessage, setEmailMessage] = useState(''); - const [passwordMessage, setPasswordMessage] = useState(''); - const [passwordConfirmMessage, setPasswordConfirmMessage] = useState(''); - const [organizationMessage, setOrganizationMessage] = useState(''); +export interface JoinParams { + email: string; + password: string; + organization: string; +} +const ManagerJoin = (): JSX.Element => { const history = useHistory(); - const isValidPassword = REGEXP.PASSWORD.test(password); - const isValidOrganization = REGEXP.ORGANIZATION.test(organization); - - const isValidEmail = useQuery(['isValidEmail', email], queryValidateEmail, { - enabled: false, - retry: false, - - onSuccess: () => { - setEmailMessage(MESSAGE.JOIN.VALID_EMAIL); - }, - - onError: (error: AxiosError) => { - setEmailMessage(error.response?.data.message ?? ''); - }, - }); - const join = useMutation(postJoin, { onSuccess: () => { alert(MESSAGE.JOIN.SUCCESS); @@ -55,116 +32,23 @@ const ManagerJoin = (): JSX.Element => { }, }); - const handleValidateEmail = () => { - if (!email) return; - - isValidEmail.refetch(); - }; - - const handleSubmitJoinForm: FormEventHandler = (event) => { - event.preventDefault(); - - if (!email || !password || !passwordConfirm || !organization) return; - - if (password !== passwordConfirm) { - alert(MESSAGE.JOIN.INVALID_PASSWORD_CONFIRM); - - return; - } + const handleSubmit = ({ email, password, organization }: JoinParams) => { + if (!email || !password || !organization) return; join.mutate({ email, password, organization }); }; - useEffect(() => { - if (!password) return; - - !isValidPassword - ? setPasswordMessage(MESSAGE.JOIN.INVALID_PASSWORD) - : setPasswordMessage(MESSAGE.JOIN.VALID_PASSWORD); - }, [password, isValidPassword]); - - useEffect(() => { - if (!password || !passwordConfirm) return; - - password !== passwordConfirm - ? setPasswordConfirmMessage(MESSAGE.JOIN.INVALID_PASSWORD_CONFIRM) - : setPasswordConfirmMessage(MESSAGE.JOIN.VALID_PASSWORD_CONFIRM); - }, [password, passwordConfirm]); - - useEffect(() => { - if (!organization) { - setOrganizationMessage(''); - return; - } - - !isValidOrganization - ? setOrganizationMessage(MESSAGE.JOIN.INVALID_ORGANIZATION) - : setOrganizationMessage(MESSAGE.JOIN.VALID_ORGANIZATION); - }, [organization, isValidOrganization]); - return ( <>
회원가입 - - - - - - - - 이미 회원이신가요? - 로그인하기 - - + + + 이미 회원이신가요? + 로그인하기 + diff --git a/frontend/src/pages/ManagerJoin/units/JoinForm.styles.ts b/frontend/src/pages/ManagerJoin/units/JoinForm.styles.ts new file mode 100644 index 000000000..32813ffb5 --- /dev/null +++ b/frontend/src/pages/ManagerJoin/units/JoinForm.styles.ts @@ -0,0 +1,9 @@ +import styled from 'styled-components'; + +export const Form = styled.form` + margin: 3.75rem 0 1rem; + + label { + margin-bottom: 3rem; + } +`; diff --git a/frontend/src/pages/ManagerJoin/units/JoinForm.tsx b/frontend/src/pages/ManagerJoin/units/JoinForm.tsx new file mode 100644 index 000000000..49c4eb165 --- /dev/null +++ b/frontend/src/pages/ManagerJoin/units/JoinForm.tsx @@ -0,0 +1,164 @@ +import { AxiosError } from 'axios'; +import React, { FormEventHandler, useEffect, useState } from 'react'; +import { useQuery } from 'react-query'; +import { queryValidateEmail } from 'api/join'; +import Button from 'components/Button/Button'; +import Input from 'components/Input/Input'; +import MANAGER from 'constants/manager'; +import MESSAGE from 'constants/message'; +import REGEXP from 'constants/regexp'; +import useInputs from 'hooks/useInputs'; +import { ErrorResponse } from 'types/response'; +import { JoinParams } from '../ManagerJoin'; +import * as Styled from './JoinForm.styles'; + +interface Form { + email: string; + password: string; + passwordConfirm: string; + organization: string; +} + +interface Props { + onSubmit: ({ email, password, organization }: JoinParams) => void; +} + +const JoinForm = ({ onSubmit }: Props): JSX.Element => { + const [{ email, password, passwordConfirm, organization }, onChangeForm] = useInputs({ + email: '', + password: '', + passwordConfirm: '', + organization: '', + }); + + const [emailMessage, setEmailMessage] = useState(''); + const [passwordMessage, setPasswordMessage] = useState(''); + const [passwordConfirmMessage, setPasswordConfirmMessage] = useState(''); + const [organizationMessage, setOrganizationMessage] = useState(''); + + const isValidPassword = REGEXP.PASSWORD.test(password); + const isValidOrganization = REGEXP.ORGANIZATION.test(organization); + + const checkValidateEmail = useQuery(['checkValidateEmail', email], queryValidateEmail, { + enabled: false, + retry: false, + + onSuccess: () => { + setEmailMessage(MESSAGE.JOIN.VALID_EMAIL); + }, + + onError: (error: AxiosError) => { + setEmailMessage(error.response?.data.message ?? ''); + }, + }); + + const handleValidateEmail = () => { + if (!email) return; + + checkValidateEmail.refetch(); + }; + + const handleSubmit: FormEventHandler = (event) => { + event.preventDefault(); + + if (password !== passwordConfirm) { + alert(MESSAGE.JOIN.INVALID_PASSWORD_CONFIRM); + + return; + } + + onSubmit({ email, password, organization }); + }; + + useEffect(() => { + if (!password) return; + + setPasswordMessage( + isValidPassword ? MESSAGE.JOIN.VALID_PASSWORD : MESSAGE.JOIN.INVALID_PASSWORD + ); + }, [password, isValidPassword]); + + useEffect(() => { + if (!password || !passwordConfirm) return; + + setPasswordConfirmMessage( + password === passwordConfirm + ? MESSAGE.JOIN.VALID_PASSWORD_CONFIRM + : MESSAGE.JOIN.INVALID_PASSWORD_CONFIRM + ); + }, [password, passwordConfirm]); + + useEffect(() => { + if (!organization) { + setOrganizationMessage(''); + + return; + } + + setOrganizationMessage( + isValidOrganization ? MESSAGE.JOIN.VALID_ORGANIZATION : MESSAGE.JOIN.INVALID_ORGANIZATION + ); + }, [organization, isValidOrganization]); + + return ( + + + + + + + + ); +}; + +export default JoinForm; diff --git a/frontend/src/pages/ManagerLogin/ManagerLogin.styles.ts b/frontend/src/pages/ManagerLogin/ManagerLogin.styles.ts index ec42bfda0..12938e0c8 100644 --- a/frontend/src/pages/ManagerLogin/ManagerLogin.styles.ts +++ b/frontend/src/pages/ManagerLogin/ManagerLogin.styles.ts @@ -13,16 +13,7 @@ export const PageTitle = styled.h2` margin: 2.125rem auto; `; -export const Form = styled.form` - margin: 3.75rem 0; - - label { - margin-bottom: 3rem; - } -`; - export const JoinLinkMessage = styled.p` - margin: 1rem 0; text-align: center; font-size: 0.75rem; color: ${({ theme }) => theme.gray[500]}; @@ -37,3 +28,14 @@ export const JoinLinkMessage = styled.p` } } `; + +export const HorizontalLine = styled.hr` + margin: 1.5rem 0; +`; + +export const SocialLogin = styled.div` + display: flex; + flex-direction: column; + gap: 1.5rem; + margin: 1.5rem 0; +`; diff --git a/frontend/src/pages/ManagerLogin/ManagerLogin.tsx b/frontend/src/pages/ManagerLogin/ManagerLogin.tsx index 67f93210c..71bb8d4bf 100644 --- a/frontend/src/pages/ManagerLogin/ManagerLogin.tsx +++ b/frontend/src/pages/ManagerLogin/ManagerLogin.tsx @@ -1,39 +1,42 @@ import { AxiosError, AxiosResponse } from 'axios'; -import { FormEventHandler, useState } from 'react'; +import { useState } from 'react'; import { useMutation } from 'react-query'; -import { Link, useHistory } from 'react-router-dom'; +import { useHistory } from 'react-router'; +import { Link } from 'react-router-dom'; import { useSetRecoilState } from 'recoil'; import { postLogin } from 'api/login'; -import Button from 'components/Button/Button'; import Header from 'components/Header/Header'; -import Input from 'components/Input/Input'; import Layout from 'components/Layout/Layout'; -import MANAGER from 'constants/manager'; +import SocialLoginButton from 'components/SocialAuthButton/SocialLoginButton'; import MESSAGE from 'constants/message'; import PATH from 'constants/path'; import { LOCAL_STORAGE_KEY } from 'constants/storage'; -import useInput from 'hooks/useInput'; import accessTokenState from 'state/accessTokenState'; import { ErrorResponse, LoginSuccess } from 'types/response'; import { setLocalStorageItem } from 'utils/localStorage'; import * as Styled from './ManagerLogin.styles'; +import LoginForm from './units/LoginForm'; -interface ErrorMessage { +export interface ErrorMessage { email?: string; password?: string; } +export interface LoginParams { + email: string; + password: string; +} + const ManagerLogin = (): JSX.Element => { - const [email, onChangeEmail] = useInput(''); - const [password, onChangePassword] = useInput(''); + const history = useHistory(); + + const setAccessToken = useSetRecoilState(accessTokenState); + const [errorMessage, setErrorMessage] = useState({ email: '', password: '', }); - const setAccessToken = useSetRecoilState(accessTokenState); - const history = useHistory(); - const login = useMutation(postLogin, { onSuccess: (response: AxiosResponse) => { const { accessToken } = response.data; @@ -56,9 +59,7 @@ const ManagerLogin = (): JSX.Element => { }, }); - const handleSubmit: FormEventHandler = (event) => { - event.preventDefault(); - + const handleSubmit = ({ email, password }: LoginParams) => { if (!(email && password)) return; login.mutate({ email, password }); @@ -70,36 +71,16 @@ const ManagerLogin = (): JSX.Element => { 로그인 - - - - - - 아직 회원이 아니신가요? - 회원가입하기 - - + + + + + + + + 아직 회원이 아니신가요? + 회원가입하기 + diff --git a/frontend/src/pages/ManagerLogin/units/LoginForm.styles.ts b/frontend/src/pages/ManagerLogin/units/LoginForm.styles.ts new file mode 100644 index 000000000..32813ffb5 --- /dev/null +++ b/frontend/src/pages/ManagerLogin/units/LoginForm.styles.ts @@ -0,0 +1,9 @@ +import styled from 'styled-components'; + +export const Form = styled.form` + margin: 3.75rem 0 1rem; + + label { + margin-bottom: 3rem; + } +`; diff --git a/frontend/src/pages/ManagerLogin/units/LoginForm.tsx b/frontend/src/pages/ManagerLogin/units/LoginForm.tsx new file mode 100644 index 000000000..6d04f27af --- /dev/null +++ b/frontend/src/pages/ManagerLogin/units/LoginForm.tsx @@ -0,0 +1,64 @@ +import { FormEventHandler } from 'react'; +import Button from 'components/Button/Button'; +import Input from 'components/Input/Input'; +import MANAGER from 'constants/manager'; +import useInputs from 'hooks/useInputs'; +import { ErrorMessage, LoginParams } from '../ManagerLogin'; + +import * as Styled from './LoginForm.styles'; + +interface Form { + email: string; + password: string; +} + +interface Props { + errorMessage: ErrorMessage; + onSubmit: ({ email, password }: LoginParams) => void; +} + +const LoginForm = ({ errorMessage, onSubmit }: Props): JSX.Element => { + const [{ email, password }, onChangeForm] = useInputs({ + email: '', + password: '', + }); + + const handleSubmit: FormEventHandler = (event) => { + event.preventDefault(); + + onSubmit({ email, password }); + }; + + return ( + + + + + + ); +}; + +export default LoginForm; diff --git a/frontend/src/pages/ManagerMain/ManagerMain.styles.ts b/frontend/src/pages/ManagerMain/ManagerMain.styles.ts index d27f8aea3..0b0fea8b3 100644 --- a/frontend/src/pages/ManagerMain/ManagerMain.styles.ts +++ b/frontend/src/pages/ManagerMain/ManagerMain.styles.ts @@ -114,3 +114,9 @@ export const IconButtonWrapper = styled.div` display: flex; gap: 0.5rem; `; + +export const PanelHeadWrapper = styled.div` + width: 100%; + display: flex; + justify-content: space-between; +`; diff --git a/frontend/src/pages/ManagerMain/ManagerMain.tsx b/frontend/src/pages/ManagerMain/ManagerMain.tsx index 4a303dfdf..c6a00de38 100644 --- a/frontend/src/pages/ManagerMain/ManagerMain.tsx +++ b/frontend/src/pages/ManagerMain/ManagerMain.tsx @@ -9,6 +9,7 @@ import { ReactComponent as EditIcon } from 'assets/svg/edit.svg'; import { ReactComponent as MapEditorIcon } from 'assets/svg/map-editor.svg'; import { ReactComponent as MenuIcon } from 'assets/svg/menu.svg'; import { ReactComponent as SpaceEditorIcon } from 'assets/svg/space-editor.svg'; +import Button from 'components/Button/Button'; import DateInput from 'components/DateInput/DateInput'; import Drawer from 'components/Drawer/Drawer'; import Header from 'components/Header/Header'; @@ -20,28 +21,34 @@ import Panel from 'components/Panel/Panel'; import ReservationListItem from 'components/ReservationListItem/ReservationListItem'; import MESSAGE from 'constants/message'; import PATH, { HREF } from 'constants/path'; -import useManagerMaps from 'hooks/useManagerMaps'; -import useManagerReservations from 'hooks/useManagerReservations'; +import useManagerMapReservations from 'hooks/query/useManagerMapReservations'; +import useManagerMaps from 'hooks/query/useManagerMaps'; +import useManagerSpaces from 'hooks/query/useManagerSpaces'; import { Order, Reservation } from 'types/common'; import { ErrorResponse, MapItemResponse } from 'types/response'; import { formatDate } from 'utils/datetime'; +import { sortReservations } from 'utils/sort'; import { isNullish } from 'utils/type'; import * as Styled from './ManagerMain.styles'; -interface LocationState { +export interface ManagerMainState { mapId?: number; + targetDate?: Date; } const ManagerMain = (): JSX.Element => { const history = useHistory(); - const location = useLocation(); + const location = useLocation(); - const [date, setDate] = useState(new Date()); + const mapId = location.state?.mapId; + const targetDate = location.state?.targetDate; + + const [date, setDate] = useState(targetDate ?? new Date()); const [open, setOpen] = useState(false); - const [selectedMapId, setSelectedMapId] = useState(location.state?.mapId ?? null); + const [selectedMapId, setSelectedMapId] = useState(mapId ?? null); const [selectedMapName, setSelectedMapName] = useState(''); - const [spacesOrder, setSpacesOrder] = useState('ascending'); + const [spacesOrder, setSpacesOrder] = useState(Order.Ascending); const onRequestError = (error: AxiosError) => { alert(error.response?.data?.message ?? MESSAGE.MANAGER_MAIN.UNEXPECTED_GET_DATA_ERROR); @@ -54,9 +61,9 @@ const ManagerMain = (): JSX.Element => { const organization = getMaps.data?.data.organization ?? ''; const maps = useMemo((): MapItemResponse[] => getMaps.data?.data.maps ?? [], [getMaps]); - const getReservations = useManagerReservations( + const getReservations = useManagerMapReservations( { - mapId: selectedMapId, + mapId: selectedMapId as number, date: formatDate(date), }, { @@ -65,6 +72,15 @@ const ManagerMain = (): JSX.Element => { } ); + const getSpaces = useManagerSpaces( + { + mapId: selectedMapId as number, + }, + { + enabled: !isNullish(selectedMapId), + } + ); + const removeReservation = useMutation(deleteManagerReservation, { onSuccess: () => { alert(MESSAGE.MANAGER_MAIN.RESERVATION_DELETE); @@ -77,27 +93,14 @@ const ManagerMain = (): JSX.Element => { const reservations = useMemo(() => getReservations.data?.data?.data ?? [], [getReservations]); const sortedReservations = useMemo( - () => - reservations.sort((a, b) => { - if (a.spaceColor !== b.spaceColor) { - if (spacesOrder === 'ascending') return a.spaceColor < b.spaceColor ? -1 : 1; - - return a.spaceColor > b.spaceColor ? -1 : 1; - } - - const aSpaceNameWithoutWhitespace = a.spaceName.replaceAll(' ', ''); - const bSpaceNameWithoutWhitespace = b.spaceName.replaceAll(' ', ''); - - if (spacesOrder === 'ascending') - return aSpaceNameWithoutWhitespace < bSpaceNameWithoutWhitespace ? -1 : 1; - - return aSpaceNameWithoutWhitespace > bSpaceNameWithoutWhitespace ? -1 : 1; - }), + () => sortReservations(reservations, spacesOrder), [reservations, spacesOrder] ); + const spaces = useMemo(() => getSpaces.data?.data.spaces ?? [], [getSpaces]); + const handleClickSpacesOrder = () => { - setSpacesOrder((prev) => (prev === 'ascending' ? 'descending' : 'ascending')); + setSpacesOrder((prev) => (prev === Order.Ascending ? Order.Descending : Order.Ascending)); }; const removeMap = useMutation(deleteMap, { @@ -163,6 +166,19 @@ const ManagerMain = (): JSX.Element => { handleCloseDrawer(); }; + const handleCreateReservation = (spaceId: number) => { + if (!selectedMapId) return; + + history.push({ + pathname: PATH.MANAGER_RESERVATION, + state: { + mapId: selectedMapId, + space: spaces?.find(({ id }) => id === spaceId), + selectedDate: formatDate(date), + }, + }); + }; + const handleEditReservation = (reservation: Reservation, spaceId: number) => { if (!selectedMapId) return; @@ -170,7 +186,7 @@ const ManagerMain = (): JSX.Element => { pathname: PATH.MANAGER_RESERVATION_EDIT, state: { mapId: selectedMapId, - spaceId, + space: spaces?.find(({ id }) => id === spaceId), reservation, selectedDate: formatDate(date), }, @@ -272,9 +288,18 @@ const ManagerMain = (): JSX.Element => { {sortedReservations && sortedReservations.map(({ spaceId, spaceName, spaceColor, reservations }, index) => ( - + - {spaceName} + + {spaceName} + + {reservations.length === 0 ? ( diff --git a/frontend/src/pages/ManagerMapCreate/ManagerMapCreate.tsx b/frontend/src/pages/ManagerMapCreate/ManagerMapCreate.tsx deleted file mode 100644 index 9038acdad..000000000 --- a/frontend/src/pages/ManagerMapCreate/ManagerMapCreate.tsx +++ /dev/null @@ -1,939 +0,0 @@ -import { AxiosError } from 'axios'; -import { - FocusEventHandler, - FormEventHandler, - MouseEvent, - MouseEventHandler, - useCallback, - useEffect, - useMemo, - useRef, - useState, - WheelEventHandler, -} from 'react'; -import { useMutation } from 'react-query'; -import { useHistory, useParams } from 'react-router-dom'; -import { postMap, putMap } from 'api/managerMap'; -import { ReactComponent as EraserIcon } from 'assets/svg/eraser.svg'; -import { ReactComponent as ItemsIcon } from 'assets/svg/items.svg'; -import { ReactComponent as LineIcon } from 'assets/svg/line.svg'; -import { ReactComponent as MoveIcon } from 'assets/svg/move.svg'; -import { ReactComponent as RectIcon } from 'assets/svg/rect.svg'; -import { ReactComponent as SelectIcon } from 'assets/svg/select.svg'; -import Button from 'components/Button/Button'; -import ColorPicker from 'components/ColorPicker/ColorPicker'; -import ColorPickerIcon from 'components/ColorPicker/ColorPickerIcon'; -import Header from 'components/Header/Header'; -import Layout from 'components/Layout/Layout'; -import { BOARD, EDITOR, KEY } from 'constants/editor'; -import MESSAGE from 'constants/message'; -import PALETTE from 'constants/palette'; -import PATH, { HREF } from 'constants/path'; -import useInput from 'hooks/useInput'; -import useListenManagerMainState from 'hooks/useListenManagerMainState'; -import useManagerMap from 'hooks/useManagerMap'; -import useManagerSpaces from 'hooks/useManagerSpaces'; -import { - Color, - Coordinate, - DrawingStatus, - EditorBoard, - GripPoint, - ManagerSpace, - MapDrawing, - MapElement, - SpaceArea, -} from 'types/common'; -import { Mode } from 'types/editor'; -import { ErrorResponse } from 'types/response'; -import * as Styled from './ManagerMapCreate.styles'; - -interface Params { - mapId?: string; -} - -const ManagerMapCreate = (): JSX.Element => { - const editorRef = useRef(null); - - const history = useHistory(); - const params = useParams(); - const mapId = params?.mapId; - const isEdit = !!mapId; - - useListenManagerMainState({ mapId: Number(mapId) }, { enabled: isEdit }); - - const [mapName, onChangeMapName, setMapName] = useInput(''); - - const [mode, setMode] = useState(Mode.Select); - const [dragOffsetX, setDragOffsetX] = useState(0); - const [dragOffsetY, setDragOffsetY] = useState(0); - const [isDragging, setDragging] = useState(false); - const [isPressSpacebar, setPressSpacebar] = useState(false); - const isDraggable = mode === Mode.Move || isPressSpacebar; - - const [color, setColor] = useState(PALETTE.BLACK[400]); - const [colorPickerOpen, setColorPickerOpen] = useState(false); - - const [coordinate, setCoordinate] = useState({ x: 0, y: 0 }); - - const [stickyPointerView, setStickyPointerView] = useState(false); - - const stickyCoordinate: Coordinate = { - x: Math.round(coordinate.x / EDITOR.GRID_SIZE) * EDITOR.GRID_SIZE, - y: Math.round(coordinate.y / EDITOR.GRID_SIZE) * EDITOR.GRID_SIZE, - }; - - const [drawingStatus, setDrawingStatus] = useState({}); - const [mapElements, setMapElements] = useState([]); - - const [gripPoints, setGripPoints] = useState([]); - const [selectedMapElementId, setSelectedMapElementId] = useState(null); - const [erasingMapElementIds, setErasingMapElementIds] = useState([]); - const [isErasing, setErasing] = useState(false); - - const nextMapElementId = Math.max(...mapElements.map(({ id }) => id), 1) + 1; - const nextGripPointId = Math.max(...gripPoints.map(({ id }) => id), 1) + 1; - - const [widthValue, onChangeWidthValue, setWidthValue] = useInput('800'); - const [heightValue, onChangeHeightValue, setHeightValue] = useInput('600'); - - const width = Number(widthValue); - const height = Number(heightValue); - - const [board, setBoard] = useState({ - width, - height, - x: 0, - y: 0, - scale: 1, - }); - - const managerSpaces = useManagerSpaces({ mapId: Number(mapId) }, { enabled: isEdit }); - const spaces: ManagerSpace[] = useMemo( - () => - managerSpaces.data?.data.spaces.map((space) => ({ - ...space, - area: JSON.parse(space.area) as SpaceArea, - })) ?? [], - [managerSpaces.data?.data.spaces] - ); - - const managerMap = useManagerMap( - { mapId: Number(mapId) }, - { - enabled: isEdit, - onSuccess: ({ data }) => { - const { mapName, mapDrawing } = data; - - setMapName(mapName ?? ''); - - try { - const { mapElements, width, height } = JSON.parse(mapDrawing) as MapDrawing; - - setMapElements(mapElements); - setWidthValue(`${width}`); - setHeightValue(`${height}`); - } catch (error) { - console.error(error); - setMapElements([]); - } - }, - } - ); - - const createMap = useMutation(postMap, { - onSuccess: (response) => { - if (window.confirm(MESSAGE.MANAGER_MAP.CREATE_SUCCESS_CONFIRM)) { - const headers = response.headers as { location: string }; - const mapId = Number(headers.location.split('/').pop()); - - history.push(HREF.MANAGER_SPACE_EDIT(mapId)); - - return; - } - - history.push(PATH.MANAGER_MAIN); - }, - onError: (error: AxiosError) => { - alert(error?.response?.data.message ?? MESSAGE.MANAGER_MAP.UNEXPECTED_MAP_CREATE_ERROR); - }, - }); - - const updateMap = useMutation(putMap, { - onSuccess: () => { - alert(MESSAGE.MANAGER_MAP.UPDATE_SUCCESS); - }, - onError: (error: AxiosError) => { - console.error(error); - }, - }); - - const getSVGCoordinate = (event: MouseEvent) => { - const svg = (event.nativeEvent.target as SVGElement)?.ownerSVGElement; - if (!svg) return { svg: null, x: -1, y: -1 }; - - let point = svg.createSVGPoint(); - - point.x = event.nativeEvent.clientX; - point.y = event.nativeEvent.clientY; - point = point.matrixTransform(svg.getScreenCTM()?.inverse()); - - const x = (point.x - board.x) * (1 / board.scale); - const y = (point.y - board.y) * (1 / board.scale); - - return { svg, x, y }; - }; - - const selectMode = (mode: Mode) => { - setDrawingStatus({}); - setCoordinate({ x: 0, y: 0 }); - setMode(mode); - }; - - const unselectMapElement = () => { - setSelectedMapElementId(null); - setGripPoints([]); - }; - - const handleMouseMove: MouseEventHandler = (event) => { - const { x, y } = getSVGCoordinate(event); - setCoordinate({ x, y }); - }; - - const handleWheel: WheelEventHandler = (event) => { - const { offsetX, offsetY, deltaY } = event.nativeEvent; - - setBoard((prevState) => { - const { scale, x, y, width, height } = prevState; - - const nextScale = scale - deltaY * EDITOR.SCALE_DELTA; - - if (nextScale <= EDITOR.MIN_SCALE || nextScale >= EDITOR.MAX_SCALE) { - return { - ...prevState, - scale: prevState.scale, - }; - } - - const cursorX = (offsetX - x) / (width * scale); - const cursorY = (offsetY - y) / (height * scale); - - const widthDiff = Math.abs(width * nextScale - width * scale) * cursorX; - const heightDiff = Math.abs(height * nextScale - height * scale) * cursorY; - - const nextX = nextScale > scale ? x - widthDiff : x + widthDiff; - const nextY = nextScale > scale ? y - heightDiff : y + heightDiff; - - return { - ...prevState, - x: nextX, - y: nextY, - scale: nextScale, - }; - }); - }; - - const handleDragStart: MouseEventHandler = (event) => { - if (!isDraggable) return; - - setDragOffsetX(event.nativeEvent.offsetX - board.x); - setDragOffsetY(event.nativeEvent.offsetY - board.y); - - setDragging(true); - }; - - const handleDrag: MouseEventHandler = (event) => { - if (!isDraggable || !isDragging) return; - - const { offsetX, offsetY } = event.nativeEvent; - - setBoard((prevState) => ({ - ...prevState, - x: offsetX - dragOffsetX, - y: offsetY - dragOffsetY, - })); - }; - - const handleDragEnd = () => { - if (!isDraggable) return; - - setDragOffsetX(0); - setDragOffsetY(0); - - setDragging(false); - }; - - const handleMouseOut = () => { - setDragging(false); - }; - - const handleSelectLineElement = (event: MouseEvent, id: MapElement['id']) => { - if (mode !== Mode.Select) return; - - const target = event.target as SVGPolylineElement; - const points = Object.values(target?.points).map(({ x, y }) => ({ x, y })); - - const newGripPoints = points.map( - (point, index): GripPoint => ({ - id: nextGripPointId + index, - mapElementId: id, - x: point.x, - y: point.y, - }) - ); - - setSelectedMapElementId(id); - setGripPoints([...newGripPoints]); - }; - - const handleSelectRectElement = (event: MouseEvent, id: MapElement['id']) => { - if (mode !== Mode.Select) return; - - const target = event.target as SVGRectElement; - - const { x, y, width, height } = target; - - const pointX = x.baseVal.value; - const pointY = y.baseVal.value; - const widthValue = width.baseVal.value; - const heightValue = height.baseVal.value; - - const points = [ - { x: pointX, y: pointY }, - { x: pointX + widthValue, y: pointY }, - { x: pointX, y: pointY + heightValue }, - { x: pointX + widthValue, y: pointY + heightValue }, - ]; - - const newGripPoints = points.map( - (point, index): GripPoint => ({ - id: nextGripPointId + index, - mapElementId: id, - x: point.x, - y: point.y, - }) - ); - - setSelectedMapElementId(id); - setGripPoints([...newGripPoints]); - }; - - const handleClickBoard: MouseEventHandler = (event) => { - unselectMapElement(); - }; - - const drawStart = () => { - if (drawingStatus.start) { - const startPoint = `${drawingStatus.start.x},${drawingStatus.start.y}`; - const endPoint = `${stickyCoordinate.x},${stickyCoordinate.y}`; - - setMapElements((prevState) => [ - ...prevState, - { - id: nextMapElementId, - type: 'polyline', - stroke: color, - points: [startPoint, endPoint], - }, - ]); - - return; - } - - if (isDragging) return; - - setDrawingStatus((prevState) => ({ - ...prevState, - start: stickyCoordinate, - })); - }; - - const drawEnd = () => { - if (!drawingStatus || !drawingStatus.start) return; - - const startPoint = `${drawingStatus.start.x},${drawingStatus.start.y}`; - const endPoint = `${stickyCoordinate.x},${stickyCoordinate.y}`; - - setDrawingStatus({}); - - if (startPoint === endPoint || isDragging) return; - - setMapElements((prevState) => [ - ...prevState, - { - id: nextMapElementId, - type: 'polyline', - stroke: color, - points: [startPoint, endPoint], - }, - ]); - }; - - const rectDrawStart = () => { - if (drawingStatus.start) { - const startPoint = `${drawingStatus.start.x},${drawingStatus.start.y}`; - const endPoint = `${stickyCoordinate.x},${stickyCoordinate.y}`; - - setMapElements((prevState) => [ - ...prevState, - { - id: nextMapElementId, - type: 'rect', - stroke: color, - points: [startPoint, endPoint], - }, - ]); - - return; - } - - if (isDragging) return; - - setDrawingStatus((prevState) => ({ - ...prevState, - start: stickyCoordinate, - })); - }; - - const rectDrawEnd = () => { - if (!drawingStatus || !drawingStatus.start) return; - - const startPoint = { - x: drawingStatus.start.x, - y: drawingStatus.start.y, - }; - - const endPoint = { - x: stickyCoordinate.x, - y: stickyCoordinate.y, - }; - - const width = Math.abs(startPoint.x - endPoint.x); - const height = Math.abs(startPoint.y - endPoint.y); - - const startCoordinate = `${startPoint.x}, ${startPoint.y}`; - const endCoordinate = `${endPoint.x}, ${endPoint.y}`; - - setDrawingStatus({}); - - if (startCoordinate === endCoordinate || isDragging) return; - - if (width && height) { - setMapElements((prevState) => [ - ...prevState, - { - id: nextMapElementId, - type: 'rect', - stroke: color, - width, - height, - x: Math.min(startPoint.x, endPoint.x), - y: Math.min(startPoint.y, endPoint.y), - points: [startCoordinate, endCoordinate], - }, - ]); - } else { - setMapElements((prevState) => [ - ...prevState, - { - id: nextMapElementId, - type: 'polyline', - stroke: color, - points: [`${startPoint.x},${startPoint.y}`, `${endPoint.x},${endPoint.y}`], - }, - ]); - } - }; - - const eraseStart = () => { - if (erasingMapElementIds.length > 0) { - eraseEnd(); - - return; - } - setErasing(true); - setErasingMapElementIds([]); - }; - - const eraseEnd = () => { - setErasing(false); - setMapElements((prevMapElements) => - prevMapElements.filter(({ id }) => !erasingMapElementIds.includes(id)) - ); - setErasingMapElementIds([]); - }; - - const handleSelectErasingElement = (id: MapElement['id']) => { - if (mode !== Mode.Eraser || !isErasing) return; - - setErasingMapElementIds((prevIds) => [...prevIds, id]); - }; - - const handleMouseDown = () => { - if (isDraggable) return; - - if (mode === Mode.Line) drawStart(); - if (mode === Mode.Rect) rectDrawStart(); - if (mode === Mode.Eraser) eraseStart(); - }; - - const handleMouseUp = () => { - if (isDraggable) return; - - if (mode === Mode.Line) drawEnd(); - if (mode === Mode.Rect) rectDrawEnd(); - if (mode === Mode.Eraser) eraseEnd(); - }; - - const deleteMapElement = useCallback(() => { - if (!selectedMapElementId) return; - - setMapElements((prevMapElements) => - prevMapElements.filter(({ id }) => id !== selectedMapElementId) - ); - unselectMapElement(); - }, [selectedMapElementId]); - - const handleKeyDown = useCallback( - (event: KeyboardEvent) => { - if ((event.target as HTMLElement).tagName === 'INPUT') return; - - if (event.key === KEY.DELETE || event.key === KEY.BACK_SPACE) { - deleteMapElement(); - } - if (event.key === KEY.SPACE) { - setPressSpacebar(true); - } - }, - [deleteMapElement] - ); - - const handleKeyUp = useCallback((event: KeyboardEvent) => { - if (event.key === KEY.SPACE) { - setPressSpacebar(false); - } - }, []); - - const handleClickCancel = () => { - if (!window.confirm(MESSAGE.MANAGER_MAP.CANCEL_CONFIRM)) return; - - history.push(PATH.MANAGER_MAIN); - }; - - const handleSubmit: FormEventHandler = (event) => { - event.preventDefault(); - - const mapDrawing = JSON.stringify({ width, height, mapElements }); - - const mapImageSvg = ` - - ${spaces - ?.map( - ({ color, area }) => ` - - - ` - ) - .join('')} - - ${mapElements - .map((element) => - element.type === 'polyline' - ? ` - - ` - : ` - - ` - ) - .join('')} - - ` - .replace(/(\r\n\t|\n|\r\t|\s{1,})/gm, ' ') - .replace(/\s{2,}/g, ' '); - - if (isEdit) { - updateMap.mutate({ mapId: Number(mapId), mapName, mapDrawing, mapImageSvg }); - return; - } - - createMap.mutate({ mapName, mapDrawing, mapImageSvg }); - }; - - const handleWidthSize: FocusEventHandler = (event) => { - if (width > BOARD.MAX_WIDTH) { - event.target.value = String(BOARD.MAX_WIDTH); - onChangeWidthValue(event); - } - - if (width < BOARD.MIN_WIDTH) { - event.target.value = String(BOARD.MIN_WIDTH); - onChangeWidthValue(event); - } - }; - - const handleHeightSize: FocusEventHandler = (event) => { - if (height > BOARD.MAX_HEIGHT) { - event.target.value = String(BOARD.MAX_HEIGHT); - onChangeHeightValue(event); - } - - if (height < BOARD.MIN_HEIGHT) { - event.target.value = String(BOARD.MIN_HEIGHT); - onChangeHeightValue(event); - } - }; - - useEffect(() => { - const editorWidth = editorRef.current ? editorRef.current.offsetWidth : 0; - const editorHeight = editorRef.current ? editorRef.current.offsetHeight : 0; - - setBoard((prevState) => ({ - ...prevState, - x: (editorWidth - width) / 2, - y: (editorHeight - height) / 2, - })); - }, [width, height]); - - useEffect(() => { - document.addEventListener('keydown', handleKeyDown); - document.addEventListener('keyup', handleKeyUp); - - return () => { - document.removeEventListener('keydown', handleKeyDown); - document.removeEventListener('keyup', handleKeyUp); - }; - }, [handleKeyDown, handleKeyUp]); - - return ( - <> - -
- - - - - - - - - {/* NOTE 추후 임시저장 기능 구현 시, 이 부분의 주석을 해제하고 작성하면 됩니다. */} - {/* - 1분 전에 임시 저장되었습니다. - - 임시 저장 - - */} - - - - - - - - - - - - selectMode(Mode.Select)} - > - - - selectMode(Mode.Move)} - > - - - selectMode(Mode.Line)} - > - - - selectMode(Mode.Rect)} - > - - - selectMode(Mode.Eraser)} - > - - - - {/* NOTE 추후 장식 기능 구현 시, 이 부분의 주석을 해제하고 작성하면 됩니다. */} - {/* selectMode(Mode.Decoration)} - > - - */} - - setColorPickerOpen(!colorPickerOpen)} - > - - - - - - - - - - - - - - - - - - - - setStickyPointerView(true)} - onMouseLeave={() => setStickyPointerView(false)} - > - - - {/* 전체 격자를 그리는 rect */} - - - {[Mode.Line, Mode.Rect].includes(mode) && stickyPointerView && ( - - )} - - {/* Note: 공간 영역 */} - {spaces.map(({ id, color, area, name }) => ( - - - - {name} - - - ))} - - {mapElements.map((element) => - element.type === 'polyline' ? ( - handleSelectLineElement(event, element.id)} - onMouseOverCapture={() => handleSelectErasingElement(element.id)} - /> - ) : ( - handleSelectRectElement(event, element.id)} - onMouseOverCapture={() => handleSelectErasingElement(element.id)} - /> - ) - )} - - {mode === Mode.Select && - gripPoints.map(({ x, y }, index) => ( - - ))} - - {drawingStatus.start && mode === Mode.Line && ( - - )} - - {drawingStatus.start && - mode === Mode.Rect && - (Math.abs(drawingStatus.start.x - stickyCoordinate.x) && - Math.abs(drawingStatus.start.y - stickyCoordinate.y) ? ( - - ) : ( - - ))} - - - - - - - - W - 넓이 - - - - - - H - 높이 - - - - - - - - - ); -}; - -export default ManagerMapCreate; diff --git a/frontend/src/pages/ManagerMapEditor/ManagerMapEditor.styles.ts b/frontend/src/pages/ManagerMapEditor/ManagerMapEditor.styles.ts new file mode 100644 index 000000000..f40355dd2 --- /dev/null +++ b/frontend/src/pages/ManagerMapEditor/ManagerMapEditor.styles.ts @@ -0,0 +1,48 @@ +import styled, { createGlobalStyle } from 'styled-components'; + +export const MapCreateGlobalStyle = createGlobalStyle` + body { + overscroll-behavior: none; + } +`; + +export const Container = styled.div` + padding: 2rem 0; + display: flex; + flex-direction: column; + height: calc(100vh - 3rem); +`; + +export const Form = styled.form` + display: flex; + flex-direction: column; + flex: 1; +`; + +export const FormHeader = styled.div` + display: flex; + justify-content: space-between; + align-items: flex-end; + margin-bottom: 0.75rem; +`; + +export const FormControl = styled.div` + display: flex; + gap: 0.5rem; +`; + +export const MapNameInput = styled.input` + border: none; + border-radius: 0.125rem; + font-size: 1.5rem; + display: inline-block; + border: 2px solid transparent; + + &:hover { + border-color: ${({ theme }) => theme.primary[400]}; + } + + &:focus { + outline-color: ${({ theme }) => theme.primary[400]}; + } +`; diff --git a/frontend/src/pages/ManagerMapEditor/ManagerMapEditor.tsx b/frontend/src/pages/ManagerMapEditor/ManagerMapEditor.tsx new file mode 100644 index 000000000..800606161 --- /dev/null +++ b/frontend/src/pages/ManagerMapEditor/ManagerMapEditor.tsx @@ -0,0 +1,172 @@ +import { AxiosError } from 'axios'; +import React, { useMemo, useState } from 'react'; +import { useMutation } from 'react-query'; +import { useHistory, useParams } from 'react-router'; +import { postMap, putMap } from 'api/managerMap'; +import Button from 'components/Button/Button'; +import Header from 'components/Header/Header'; +import Layout from 'components/Layout/Layout'; +import MESSAGE from 'constants/message'; +import PATH, { HREF } from 'constants/path'; +import useManagerMap from 'hooks/query/useManagerMap'; +import useManagerSpaces from 'hooks/query/useManagerSpaces'; +import useInputs from 'hooks/useInputs'; +import useListenManagerMainState from 'hooks/useListenManagerMainState'; +import { ManagerSpace, MapDrawing, MapElement, SpaceArea } from 'types/common'; +import { ErrorResponse } from 'types/response'; +import { createMapImageSvg } from 'utils/map'; +import * as Styled from './ManagerMapEditor.styles'; +import MapEditor from './units/MapEditor'; + +interface Params { + mapId?: string; +} + +interface Board { + name: string; + width: string; + height: string; +} + +const ManagerMapEditor = (): JSX.Element => { + const history = useHistory(); + const params = useParams(); + const mapId = params?.mapId; + const isEdit = !!mapId; + + const [mapElements, setMapElements] = useState([]); + const [{ name, width, height }, onChangeBoard, setBoard] = useInputs({ + name: '', + width: '800', + height: '600', + }); + + const managerSpaces = useManagerSpaces({ mapId: Number(mapId) }, { enabled: isEdit }); + const spaces: ManagerSpace[] = useMemo(() => { + try { + return ( + managerSpaces.data?.data.spaces.map((space) => ({ + ...space, + area: JSON.parse(space.area) as SpaceArea, + })) ?? [] + ); + } catch (error) { + return []; + } + }, [managerSpaces.data?.data.spaces]); + + useManagerMap( + { mapId: Number(mapId) }, + { + enabled: isEdit, + onSuccess: ({ data }) => { + const { mapName, mapDrawing } = data; + + try { + const { mapElements, width, height } = JSON.parse(mapDrawing) as MapDrawing; + + setMapElements(mapElements); + setBoard({ + name: mapName ?? '', + width: `${width}`, + height: `${height}`, + }); + } catch (error) { + setMapElements([]); + } + }, + } + ); + + const createMap = useMutation(postMap, { + onSuccess: (response) => { + if (window.confirm(MESSAGE.MANAGER_MAP.CREATE_SUCCESS_CONFIRM)) { + const headers = response.headers as { location: string }; + const mapId = Number(headers.location.split('/').pop()); + + history.push(HREF.MANAGER_SPACE_EDIT(mapId)); + + return; + } + + history.push(PATH.MANAGER_MAIN); + }, + onError: (error: AxiosError) => { + alert(error?.response?.data.message ?? MESSAGE.MANAGER_MAP.UNEXPECTED_MAP_CREATE_ERROR); + }, + }); + + const updateMap = useMutation(putMap, { + onSuccess: () => { + alert(MESSAGE.MANAGER_MAP.UPDATE_SUCCESS); + }, + onError: (error: AxiosError) => { + alert(error?.response?.data.message ?? MESSAGE.MANAGER_MAP.UNEXPECTED_MAP_UPDATE_ERROR); + }, + }); + + const handleCancel = () => { + if (!window.confirm(MESSAGE.MANAGER_MAP.CANCEL_CONFIRM)) return; + + history.push(PATH.MANAGER_MAIN); + }; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + if (createMap.isLoading || updateMap.isLoading) return; + + const mapDrawing = JSON.stringify({ width, height, mapElements }); + const mapImageSvg = createMapImageSvg({ + mapElements, + spaces, + width, + height, + }); + + if (isEdit) { + updateMap.mutate({ mapId: Number(mapId), mapName: name, mapDrawing, mapImageSvg }); + + return; + } + + createMap.mutate({ mapName: name, mapDrawing, mapImageSvg }); + }; + + useListenManagerMainState({ mapId: Number(mapId) }, { enabled: isEdit }); + + return ( + <> + +
+ + + + + + + + + + + + + + + + ); +}; + +export default ManagerMapEditor; diff --git a/frontend/src/pages/ManagerMapEditor/hooks/useBoardEraserTool.ts b/frontend/src/pages/ManagerMapEditor/hooks/useBoardEraserTool.ts new file mode 100644 index 000000000..508484c1a --- /dev/null +++ b/frontend/src/pages/ManagerMapEditor/hooks/useBoardEraserTool.ts @@ -0,0 +1,60 @@ +import { Dispatch, SetStateAction, useState } from 'react'; +import { MapElement } from 'types/common'; + +interface Props { + mapElements: [MapElement[], Dispatch>]; +} + +const useBoardEraserTool = ({ + mapElements: [, setMapElements], +}: Props): { + erasingMapElementIds: MapElement['id'][]; + isErasing: boolean; + eraseStart: () => void; + eraseEnd: () => void; + onMouseOverMapElement: (event: React.MouseEvent) => void; +} => { + const [erasingMapElementIds, setErasingMapElementIds] = useState([]); + const [isErasing, setErasing] = useState(false); + + const eraseEnd = () => { + setErasing(false); + setMapElements((prevMapElements) => + prevMapElements.filter(({ id }) => !erasingMapElementIds.includes(id)) + ); + setErasingMapElementIds([]); + }; + + const eraseStart = () => { + if (erasingMapElementIds.length > 0) { + eraseEnd(); + + return; + } + setErasing(true); + setErasingMapElementIds([]); + }; + + const selectErasingElement = (id: MapElement['id']) => { + if (!isErasing) return; + + setErasingMapElementIds((prevIds) => [...prevIds, id]); + }; + + const onMouseOverMapElement = (event: React.MouseEvent) => { + const target = event.target as SVGElement; + const [, mapElementId] = target.id.split('-'); + + selectErasingElement(Number(mapElementId)); + }; + + return { + erasingMapElementIds, + isErasing, + eraseStart, + eraseEnd, + onMouseOverMapElement, + }; +}; + +export default useBoardEraserTool; diff --git a/frontend/src/pages/ManagerMapEditor/hooks/useBoardLineTool.ts b/frontend/src/pages/ManagerMapEditor/hooks/useBoardLineTool.ts new file mode 100644 index 000000000..b271a45f5 --- /dev/null +++ b/frontend/src/pages/ManagerMapEditor/hooks/useBoardLineTool.ts @@ -0,0 +1,74 @@ +import { Dispatch, SetStateAction } from 'react'; +import { Color, Coordinate, DrawingStatus, MapElement } from 'types/common'; +import { MapElementType } from 'types/editor'; + +interface Props { + coordinate: Coordinate; + color: Color; + drawingStatus: [DrawingStatus, Dispatch>]; + mapElements: [MapElement[], Dispatch>]; +} + +const useBoardLineTool = ({ + coordinate, + color, + drawingStatus: [drawingStatus, setDrawingStatus], + mapElements: [mapElements, setMapElements], +}: Props): { + drawLineStart: () => void; + drawLineEnd: () => void; +} => { + const nextMapElementId = Math.max(...mapElements.map(({ id }) => id), 1) + 1; + + const drawLineStart = () => { + if (drawingStatus.start) { + const startPoint = `${drawingStatus.start.x},${drawingStatus.start.y}`; + const endPoint = `${coordinate.x},${coordinate.y}`; + + setMapElements((prevState) => [ + ...prevState, + { + id: nextMapElementId, + type: MapElementType.Polyline, + stroke: color, + points: [startPoint, endPoint], + }, + ]); + + return; + } + + setDrawingStatus((prevState) => ({ + ...prevState, + start: coordinate, + })); + }; + + const drawLineEnd = () => { + if (!drawingStatus || !drawingStatus.start) return; + + const startPoint = `${drawingStatus.start.x},${drawingStatus.start.y}`; + const endPoint = `${coordinate.x},${coordinate.y}`; + + setDrawingStatus({}); + + if (startPoint === endPoint) return; + + setMapElements((prevState) => [ + ...prevState, + { + id: nextMapElementId, + type: MapElementType.Polyline, + stroke: color, + points: [startPoint, endPoint], + }, + ]); + }; + + return { + drawLineStart, + drawLineEnd, + }; +}; + +export default useBoardLineTool; diff --git a/frontend/src/pages/ManagerMapEditor/hooks/useBoardRectTool.ts b/frontend/src/pages/ManagerMapEditor/hooks/useBoardRectTool.ts new file mode 100644 index 000000000..1640e760a --- /dev/null +++ b/frontend/src/pages/ManagerMapEditor/hooks/useBoardRectTool.ts @@ -0,0 +1,105 @@ +import { Dispatch, SetStateAction } from 'react'; +import { Color, Coordinate, DrawingStatus, MapElement } from 'types/common'; +import { MapElementType } from 'types/editor'; + +interface Props { + coordinate: Coordinate; + color: Color; + drawingStatus: [DrawingStatus, Dispatch>]; + mapElements: [MapElement[], Dispatch>]; +} + +const useBoardRectTool = ({ + coordinate, + color, + drawingStatus: [drawingStatus, setDrawingStatus], + mapElements: [mapElements, setMapElements], +}: Props): { + drawRectStart: () => void; + drawRectEnd: () => void; +} => { + const nextMapElementId = Math.max(...mapElements.map(({ id }) => id), 1) + 1; + + const drawRectStart = () => { + if (drawingStatus.start) { + const startPoint = `${drawingStatus.start.x},${drawingStatus.start.y}`; + const endPoint = `${coordinate.x},${coordinate.y}`; + + setMapElements((prevState) => [ + ...prevState, + { + id: nextMapElementId, + type: MapElementType.Rect, + stroke: color, + points: [startPoint, endPoint], + }, + ]); + + return; + } + + setDrawingStatus((prevState) => ({ + ...prevState, + start: coordinate, + })); + }; + + const drawRectEnd = () => { + if (!drawingStatus || !drawingStatus.start) return; + + const startPoint = { + x: drawingStatus.start.x, + y: drawingStatus.start.y, + }; + + const endPoint = { + x: coordinate.x, + y: coordinate.y, + }; + + const width = Math.abs(startPoint.x - endPoint.x); + const height = Math.abs(startPoint.y - endPoint.y); + + const startCoordinate = `${startPoint.x}, ${startPoint.y}`; + const endCoordinate = `${endPoint.x}, ${endPoint.y}`; + + setDrawingStatus({}); + + if (startCoordinate === endCoordinate) return; + + if (!width || !height) { + setMapElements((prevState) => [ + ...prevState, + { + id: nextMapElementId, + type: MapElementType.Polyline, + stroke: color, + points: [`${startPoint.x},${startPoint.y}`, `${endPoint.x},${endPoint.y}`], + }, + ]); + + return; + } + + setMapElements((prevState) => [ + ...prevState, + { + id: nextMapElementId, + type: MapElementType.Rect, + stroke: color, + width, + height, + x: Math.min(startPoint.x, endPoint.x), + y: Math.min(startPoint.y, endPoint.y), + points: [startCoordinate, endCoordinate], + }, + ]); + }; + + return { + drawRectStart, + drawRectEnd, + }; +}; + +export default useBoardRectTool; diff --git a/frontend/src/pages/ManagerMapEditor/hooks/useBoardSelect.ts b/frontend/src/pages/ManagerMapEditor/hooks/useBoardSelect.ts new file mode 100644 index 000000000..9bd919197 --- /dev/null +++ b/frontend/src/pages/ManagerMapEditor/hooks/useBoardSelect.ts @@ -0,0 +1,92 @@ +import React, { useState } from 'react'; +import { Coordinate, GripPoint, MapElement } from 'types/common'; + +const useBoardSelect = (): { + gripPoints: GripPoint[]; + selectedMapElementId: number | null; + deselectMapElement: () => void; + onClickBoard: () => void; + onClickMapElement: (event: React.MouseEvent) => void; +} => { + const [gripPoints, setGripPoints] = useState([]); + const [selectedMapElementId, setSelectedMapElementId] = useState(null); + const nextGripPointId = Math.max(...gripPoints.map(({ id }) => id), 1) + 1; + + const selectLineElement = (target: SVGPolylineElement, id: MapElement['id']) => { + const points = Object.values(target?.points).map(({ x, y }) => ({ x, y })); + + const newGripPoints = points.map( + (point, index): GripPoint => ({ + id: nextGripPointId + index, + mapElementId: id, + x: point.x, + y: point.y, + }) + ); + + setSelectedMapElementId(id); + setGripPoints([...newGripPoints]); + }; + + const selectRectElement = (target: SVGRectElement, id: MapElement['id']) => { + const { x, y, width, height } = target; + + const pointX = x.baseVal.value; + const pointY = y.baseVal.value; + const widthValue = width.baseVal.value; + const heightValue = height.baseVal.value; + + const points = [ + { x: pointX, y: pointY }, + { x: pointX + widthValue, y: pointY }, + { x: pointX, y: pointY + heightValue }, + { x: pointX + widthValue, y: pointY + heightValue }, + ]; + + const newGripPoints = points.map( + (point, index): GripPoint => ({ + id: nextGripPointId + index, + mapElementId: id, + x: point.x, + y: point.y, + }) + ); + + setSelectedMapElementId(id); + setGripPoints([...newGripPoints]); + }; + + const deselectMapElement = () => { + setSelectedMapElementId(null); + setGripPoints([]); + }; + + const onClickBoard = () => { + deselectMapElement(); + }; + + const onClickMapElement = (event: React.MouseEvent) => { + const target = event.target as SVGElement; + const [mapElementType, mapElementId] = target.id.split('-'); + + if (mapElementType === 'polyline') { + selectLineElement(event.target as SVGPolylineElement, Number(mapElementId)); + + return; + } + + if (mapElementType === 'rect') { + selectRectElement(event.target as SVGRectElement, Number(mapElementId)); + } + }; + + return { + gripPoints, + selectedMapElementId, + deselectMapElement, + onClickBoard, + onClickMapElement, + }; +}; + +export default useBoardSelect; diff --git a/frontend/src/pages/ManagerMapCreate/ManagerMapCreate.styles.ts b/frontend/src/pages/ManagerMapEditor/units/MapEditor.styles.ts similarity index 53% rename from frontend/src/pages/ManagerMapCreate/ManagerMapCreate.styles.ts rename to frontend/src/pages/ManagerMapEditor/units/MapEditor.styles.ts index 09922e05e..53e46f315 100644 --- a/frontend/src/pages/ManagerMapCreate/ManagerMapCreate.styles.ts +++ b/frontend/src/pages/ManagerMapEditor/units/MapEditor.styles.ts @@ -1,5 +1,4 @@ -import styled, { createGlobalStyle, css } from 'styled-components'; -import Button from 'components/Button/Button'; +import styled, { css } from 'styled-components'; import IconButton from 'components/IconButton/IconButton'; import Input from 'components/Input/Input'; @@ -7,95 +6,14 @@ interface ToolbarButtonProps { selected?: boolean; } -interface BoardContainerProps { - isDraggable?: boolean; - isDragging?: boolean; -} - -export const PageGlobalStyle = createGlobalStyle` - body { - overscroll-behavior: none; - } -`; - const primaryIconCSS = css` svg { fill: ${({ theme }) => theme.primary[400]}; } `; -export const Container = styled.div` - padding: 2rem 0; - display: flex; - flex-direction: column; - height: calc(100vh - 3rem); -`; - -export const ToolbarButton = styled(IconButton)` - background-color: ${({ theme, selected }) => (selected ? theme.gray[100] : 'none')}; - border: 1px solid ${({ theme, selected }) => (selected ? theme.gray[400] : 'transparent')}; - border-radius: 0; - box-sizing: content-box; - - ${({ selected }) => selected && primaryIconCSS} -`; - -export const EditorHeader = styled.div``; - -export const Form = styled.form` - display: flex; - justify-content: space-between; - align-items: flex-end; - margin-bottom: 0.75rem; -`; - -export const HeaderContent = styled.div``; - -export const MapNameContainer = styled.div``; - -export const TempSaveContainer = styled.div` - margin-top: 0.25rem; -`; - -export const TempSaveMessage = styled.p` - display: inline; - color: ${({ theme }) => theme.gray[400]}; - font-size: 0.875rem; -`; - -export const TempSaveButton = styled(Button)` - padding: 0; - margin-left: 0.5rem; - font-size: 0.875rem; -`; - -export const MapNameInput = styled.input` - border: none; - border-radius: 0.125rem; - font-size: 1.5rem; - display: inline-block; - border: 2px solid transparent; - - &:hover { - border-color: ${({ theme }) => theme.primary[400]}; - } - - &:focus { - outline-color: ${({ theme }) => theme.primary[400]}; - } -`; - -export const MapName = styled.h3` - font-size: 1.5rem; - display: inline-block; -`; - -export const ButtonContainer = styled.div` - display: flex; - gap: 0.5rem; -`; - -export const EditorContent = styled.div` +export const Editor = styled.div` + position: relative; display: flex; flex: 1; border-top: 1px solid ${({ theme }) => theme.gray[400]}; @@ -114,20 +32,23 @@ export const Toolbar = styled.div` gap: 1rem; `; -export const Editor = styled.div` - flex: 1; +export const ToolbarButton = styled(IconButton)` + background-color: ${({ theme, selected }) => (selected ? theme.gray[100] : 'none')}; + border: 1px solid ${({ theme, selected }) => (selected ? theme.gray[400] : 'transparent')}; + border-radius: 0; + box-sizing: content-box; + + ${({ selected }) => selected && primaryIconCSS} `; -export const BoardContainer = styled.svg` - outline: none; +export const ColorPicker = styled.div` + position: absolute; + left: 3.75rem; + top: 19rem; +`; - cursor: ${({ isDraggable, isDragging }) => { - if (isDraggable) { - if (isDragging) return 'grabbing'; - else return 'grab'; - } - return 'default'; - }}; +export const Board = styled.div` + flex: 1; `; export const InputWrapper = styled.div` @@ -137,10 +58,16 @@ export const InputWrapper = styled.div` margin: 0 0.25rem; `; -export const ColorPickerWrapper = styled.div` - position: absolute; - left: 4.25rem; - top: 22rem; +export const Label = styled.div` + color: ${({ theme }) => theme.gray[500]}; + text-align: center; + user-select: none; +`; + +export const LabelIcon = styled.div``; + +export const LabelText = styled.div` + font-size: 0.625rem; `; export const SizeInput = styled(Input)` @@ -162,18 +89,6 @@ export const SizeInput = styled(Input)` } `; -export const Label = styled.div` - color: ${({ theme }) => theme.gray[500]}; - text-align: center; - user-select: none; -`; - -export const LabelIcon = styled.div``; - -export const LabelText = styled.div` - font-size: 0.625rem; -`; - export const GripPoint = styled.circle` fill: ${({ theme }) => theme.white}; stroke: ${({ theme }) => theme.black[100]}; diff --git a/frontend/src/pages/ManagerMapEditor/units/MapEditor.tsx b/frontend/src/pages/ManagerMapEditor/units/MapEditor.tsx new file mode 100644 index 000000000..87e9a8355 --- /dev/null +++ b/frontend/src/pages/ManagerMapEditor/units/MapEditor.tsx @@ -0,0 +1,326 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { ReactComponent as EraserIcon } from 'assets/svg/eraser.svg'; +import { ReactComponent as LineIcon } from 'assets/svg/line.svg'; +import { ReactComponent as MoveIcon } from 'assets/svg/move.svg'; +import { ReactComponent as RectIcon } from 'assets/svg/rect.svg'; +import { ReactComponent as SelectIcon } from 'assets/svg/select.svg'; +import Board from 'components/Board/Board'; +import ColorPicker from 'components/ColorPicker/ColorPicker'; +import ColorPickerIcon from 'components/ColorPicker/ColorPickerIcon'; +import { EDITOR, KEY } from 'constants/editor'; +import PALETTE from 'constants/palette'; +import useBindKeyPress from 'hooks/board/useBindKeyPress'; +import useBoardCoordinate from 'hooks/board/useBoardCoordinate'; +import useBoardMove from 'hooks/board/useBoardMove'; +import useBoardStatus from 'hooks/board/useBoardStatus'; +import useBoardZoom from 'hooks/board/useBoardZoom'; +import { Color, DrawingStatus, ManagerSpace, MapElement } from 'types/common'; +import { MapElementType, MapEditorMode } from 'types/editor'; +import useBoardEraserTool from '../hooks/useBoardEraserTool'; +import useBoardLineTool from '../hooks/useBoardLineTool'; +import useBoardRectTool from '../hooks/useBoardRectTool'; +import useBoardSelect from '../hooks/useBoardSelect'; +import * as Styled from './MapEditor.styles'; + +const toolbarItems = [ + { + text: '선택', + mode: MapEditorMode.Select, + icon: , + }, + { + text: '이동', + mode: MapEditorMode.Move, + icon: , + }, + { + text: '선', + mode: MapEditorMode.Line, + icon: , + }, + { + text: '사각형', + mode: MapEditorMode.Rect, + icon: , + }, + { + text: '지우개', + mode: MapEditorMode.Eraser, + icon: , + }, +]; + +interface Props { + spaces: ManagerSpace[]; + mapElementsState: [MapElement[], React.Dispatch>]; + boardState: [ + { width: string; height: string }, + (event: React.ChangeEvent) => void + ]; +} + +const MapCreateEditor = ({ + spaces, + mapElementsState: [mapElements, setMapElements], + boardState: [{ width, height }, onChangeBoard], +}: Props): JSX.Element => { + const [mode, setMode] = useState(MapEditorMode.Select); + + const [color, setColor] = useState(PALETTE.BLACK[400]); + const [isColorPickerOpen, setColorPickerOpen] = useState(false); + + const [drawingStatus, setDrawingStatus] = useState({}); + + const { pressedKey } = useBindKeyPress(); + const isPressSpacebar = pressedKey === KEY.SPACE; + const isBoardDraggable = isPressSpacebar || mode === MapEditorMode.Move; + const isMapElementClickable = mode === MapEditorMode.Select && !isBoardDraggable; + const isMapElementEventAvailable = + [MapEditorMode.Select, MapEditorMode.Eraser].includes(mode) && !isBoardDraggable; + + const [boardStatus, setBoardStatus] = useBoardStatus({ + width: Number(width), + height: Number(height), + }); + const { stickyDotCoordinate, onMouseMove } = useBoardCoordinate(boardStatus); + const { onWheel } = useBoardZoom([boardStatus, setBoardStatus]); + const { gripPoints, selectedMapElementId, deselectMapElement, onClickBoard, onClickMapElement } = + useBoardSelect(); + const { isMoving, onDragStart, onDrag, onDragEnd, onMouseOut } = useBoardMove( + [boardStatus, setBoardStatus], + isBoardDraggable + ); + const { drawLineStart, drawLineEnd } = useBoardLineTool({ + coordinate: stickyDotCoordinate, + color, + drawingStatus: [drawingStatus, setDrawingStatus], + mapElements: [mapElements, setMapElements], + }); + const { drawRectStart, drawRectEnd } = useBoardRectTool({ + coordinate: stickyDotCoordinate, + color, + drawingStatus: [drawingStatus, setDrawingStatus], + mapElements: [mapElements, setMapElements], + }); + const { erasingMapElementIds, eraseStart, eraseEnd, onMouseOverMapElement } = useBoardEraserTool({ + mapElements: [mapElements, setMapElements], + }); + + const toggleColorPicker = () => setColorPickerOpen((prevState) => !prevState); + + const selectMode = (mode: MapEditorMode) => { + setDrawingStatus({}); + setMode(mode); + }; + + const handleMouseDown = () => { + if (isBoardDraggable || isMoving) return; + + if (mode === MapEditorMode.Line) drawLineStart(); + else if (mode === MapEditorMode.Rect) drawRectStart(); + else if (mode === MapEditorMode.Eraser) eraseStart(); + }; + + const handleMouseUp = () => { + if (isBoardDraggable || isMoving) return; + + if (mode === MapEditorMode.Line) drawLineEnd(); + else if (mode === MapEditorMode.Rect) drawRectEnd(); + else if (mode === MapEditorMode.Eraser) eraseEnd(); + }; + + const deleteMapElement = useCallback(() => { + if (!selectedMapElementId) return; + + setMapElements((prevMapElements) => + prevMapElements.filter(({ id }) => id !== selectedMapElementId) + ); + + deselectMapElement(); + }, [deselectMapElement, selectedMapElementId, setMapElements]); + + useEffect(() => { + if (mode !== MapEditorMode.Select) return; + + const isPressedDeleteKey = pressedKey === KEY.DELETE || pressedKey === KEY.BACK_SPACE; + + if (isPressedDeleteKey && selectedMapElementId) { + deleteMapElement(); + } + }, [deleteMapElement, mode, pressedKey, selectedMapElementId]); + + return ( + + + {toolbarItems.map((item) => ( + selectMode(item.mode)} + > + {item.icon} + + ))} + + + + + + + + + + {[MapEditorMode.Line, MapEditorMode.Rect].includes(mode) && ( + + )} + + {spaces.map(({ id, color, area, name }) => ( + + + + {name} + + + ))} + + {drawingStatus.start && mode === MapEditorMode.Line && ( + + )} + + {drawingStatus.start && mode === MapEditorMode.Rect && ( + + )} + + {mapElements.map((element) => { + if (element.type === MapElementType.Polyline) { + return ( + + ); + } + + if (element.type === MapElementType.Rect) { + return ( + + ); + } + + return null; + })} + + {mode === MapEditorMode.Select && + gripPoints.map(({ x, y }, index) => ( + + ))} + + + + + + W + 넓이 + + + + + + H + 높이 + + + + + + ); +}; + +export default MapCreateEditor; diff --git a/frontend/src/pages/ManagerReservation/ManagerReservation.styles.ts b/frontend/src/pages/ManagerReservation/ManagerReservation.styles.ts new file mode 100644 index 000000000..95a31894b --- /dev/null +++ b/frontend/src/pages/ManagerReservation/ManagerReservation.styles.ts @@ -0,0 +1,31 @@ +import styled from 'styled-components'; +import ColorDotComponent from 'components/ColorDot/ColorDot'; + +interface Props { + isEditMode: boolean; +} + +export const Section = styled.section` + margin-top: ${({ isEditMode }) => (isEditMode ? '3rem' : '1.5rem')}; + margin-bottom: 4.5rem; +`; + +export const Message = styled.p` + white-space: pre-wrap; +`; + +export const ReservationList = styled.div` + border-top: 1px solid ${({ theme }) => theme.gray[400]}; +`; + +export const PageHeader = styled.h2` + font-size: 1.625rem; + font-weight: 700; + margin: 1.5rem 0; + display: flex; + align-items: center; +`; + +export const ColorDot = styled(ColorDotComponent)` + margin-right: 0.75rem; +`; diff --git a/frontend/src/pages/ManagerReservation/ManagerReservation.tsx b/frontend/src/pages/ManagerReservation/ManagerReservation.tsx new file mode 100644 index 000000000..b20b2238e --- /dev/null +++ b/frontend/src/pages/ManagerReservation/ManagerReservation.tsx @@ -0,0 +1,157 @@ +import { AxiosError } from 'axios'; +import { useEffect } from 'react'; +import { useMutation } from 'react-query'; +import { useHistory, useLocation } from 'react-router-dom'; +import { + postManagerReservation, + PostReservationParams, + putManagerReservation, + PutReservationParams, +} from 'api/managerReservation'; +import Header from 'components/Header/Header'; +import Layout from 'components/Layout/Layout'; +import PageHeader from 'components/PageHeader/PageHeader'; +import ReservationListItem from 'components/ReservationListItem/ReservationListItem'; +import MESSAGE from 'constants/message'; +import PATH from 'constants/path'; +import useManagerSpaceReservations from 'hooks/query/useManagerSpaceReservations'; +import useInput from 'hooks/useInput'; +import { ManagerMainState } from 'pages/ManagerMain/ManagerMain'; +import { ManagerSpaceAPI, Reservation } from 'types/common'; +import { ErrorResponse } from 'types/response'; +import * as Styled from './ManagerReservation.styles'; +import ManagerReservationForm from './units/ManagerReservationForm'; + +export type EditReservationParams = Omit; +export type CreateReservationParams = Omit; + +interface ManagerReservationState { + mapId: number; + space: ManagerSpaceAPI; + selectedDate: string; + reservation?: Reservation; +} + +const ManagerReservation = (): JSX.Element => { + const location = useLocation(); + const history = useHistory(); + + const { mapId, space, selectedDate, reservation } = location.state; + + if (!mapId || !space) history.replace(PATH.MANAGER_MAIN); + + const [date, onChangeDate] = useInput(selectedDate); + + const isEditMode = !!reservation; + + const getReservations = useManagerSpaceReservations({ mapId, spaceId: space.id, date }); + const reservations = getReservations.data?.data?.reservations ?? []; + + const addReservation = useMutation(postManagerReservation, { + onSuccess: () => { + history.push({ + pathname: PATH.MANAGER_MAIN, + state: { + mapId, + targetDate: new Date(date), + }, + }); + }, + onError: (error: AxiosError) => { + alert(error.response?.data.message ?? MESSAGE.RESERVATION.UNEXPECTED_ERROR); + }, + }); + + const updateReservation = useMutation(putManagerReservation, { + onSuccess: () => { + history.push({ + pathname: PATH.MANAGER_MAIN, + state: { + mapId, + targetDate: new Date(date), + }, + }); + }, + + onError: (error: AxiosError) => { + alert(error.response?.data.message ?? MESSAGE.RESERVATION.UNEXPECTED_ERROR); + }, + }); + + const createReservation = ({ reservation }: CreateReservationParams) => { + if (addReservation.isLoading) return; + + addReservation.mutate({ + reservation, + mapId, + spaceId: space.id, + }); + }; + + const editReservation = ({ reservation, reservationId }: EditReservationParams) => { + if (updateReservation.isLoading || !isEditMode || !reservationId) return; + + updateReservation.mutate({ + reservation, + mapId, + spaceId: space.id, + reservationId, + }); + }; + + useEffect(() => { + return history.listen((location) => { + if ( + location.pathname === PATH.MANAGER_MAIN || + location.pathname === PATH.MANAGER_MAIN + '/' + ) { + location.state = { + mapId, + targetDate: new Date(date), + }; + } + }); + }, [history, date, mapId]); + + return ( + <> +
+ + + + {space.name} + + + + + {getReservations.isLoadingError && ( + {MESSAGE.RESERVATION.ERROR} + )} + {getReservations.isLoading && !getReservations.isLoadingError && ( + {MESSAGE.RESERVATION.PENDING} + )} + {getReservations.isSuccess && reservations.length === 0 && ( + {MESSAGE.RESERVATION.SUGGESTION} + )} + {getReservations.isSuccess && reservations.length > 0 && ( + + {reservations?.map((reservation) => ( + + ))} + + )} + + + + ); +}; + +export default ManagerReservation; diff --git a/frontend/src/pages/ManagerReservationEdit/ManagerReservationEdit.styles.ts b/frontend/src/pages/ManagerReservation/units/ManagerReservationForm.styles.ts similarity index 51% rename from frontend/src/pages/ManagerReservationEdit/ManagerReservationEdit.styles.ts rename to frontend/src/pages/ManagerReservation/units/ManagerReservationForm.styles.ts index 1ba1cfc8d..5723fe860 100644 --- a/frontend/src/pages/ManagerReservationEdit/ManagerReservationEdit.styles.ts +++ b/frontend/src/pages/ManagerReservation/units/ManagerReservationForm.styles.ts @@ -1,18 +1,7 @@ import styled from 'styled-components'; -import { Color } from 'types/common'; - -interface ColorDotProps { - color: Color; -} export const ReservationForm = styled.form` - margin: 1.5rem 0 5rem 0; -`; - -export const PageHeader = styled.h2` - font-size: 1.625rem; - font-weight: 700; - margin: 1.5rem 0; + margin: 1.5rem 0 0; `; export const Section = styled.section` @@ -30,10 +19,6 @@ export const InputWrapper = styled.div` } `; -export const ReservationList = styled.div` - border-top: 1px solid ${({ theme }) => theme.gray[400]}; -`; - export const ButtonWrapper = styled.div` position: fixed; bottom: 0; @@ -49,14 +34,3 @@ export const TimeFormMessage = styled.p` height: 1em; color: ${({ theme }) => theme.gray[500]}; `; - -export const Message = styled.p``; - -export const ColorDot = styled.span` - display: inline-block; - width: 1rem; - height: 1rem; - background-color: ${({ color }) => color}; - border-radius: 50%; - margin-right: 0.75rem; -`; diff --git a/frontend/src/pages/ManagerReservation/units/ManagerReservationForm.tsx b/frontend/src/pages/ManagerReservation/units/ManagerReservationForm.tsx new file mode 100644 index 000000000..91f6361dc --- /dev/null +++ b/frontend/src/pages/ManagerReservation/units/ManagerReservationForm.tsx @@ -0,0 +1,205 @@ +import { ChangeEventHandler } from 'react'; +import { ReactComponent as CalendarIcon } from 'assets/svg/calendar.svg'; +import Button from 'components/Button/Button'; +import Input from 'components/Input/Input'; +import MESSAGE from 'constants/message'; +import REGEXP from 'constants/regexp'; +import RESERVATION from 'constants/reservation'; +import TIME from 'constants/time'; +import useInputs from 'hooks/useInputs'; +import useScrollToTop from 'hooks/useScrollToTop'; +import { ManagerSpaceAPI, Reservation } from 'types/common'; +import { formatDate, formatTime, formatTimePrettier } from 'utils/datetime'; +import { CreateReservationParams, EditReservationParams } from '../ManagerReservation'; +import * as Styled from './ManagerReservationForm.styles'; + +interface Props { + isEditMode: boolean; + space: ManagerSpaceAPI; + reservation?: Reservation; + date: string; + onChangeDate: ChangeEventHandler; + onCreateReservation: ({ reservation }: CreateReservationParams) => void; + onEditReservation: ({ reservation, reservationId }: EditReservationParams) => void; +} + +interface Form { + name: string; + description: string; + startTime: string; + endTime: string; + password: string; +} + +const ManagerReservationForm = ({ + isEditMode, + space, + date, + reservation, + onChangeDate, + onCreateReservation, + onEditReservation, +}: Props): JSX.Element => { + useScrollToTop(); + + const { availableStartTime, availableEndTime, reservationTimeUnit, reservationMaximumTimeUnit } = + space.settings; + + const now = new Date(); + const todayDate = formatDate(new Date()); + + const getInitialStartTime = () => { + if (isEditMode && reservation) { + return formatTime(new Date(reservation.startDateTime)); + } + + return formatTime(now); + }; + + const getInitialEndTime = () => { + if (isEditMode && reservation) { + return formatTime(new Date(reservation.endDateTime)); + } + + return formatTime( + new Date(new Date().getTime() + TIME.MILLISECONDS_PER_MINUTE * reservationTimeUnit) + ); + }; + + const initialStartTime = getInitialStartTime(); + const initialEndTime = getInitialEndTime(); + + const availableStartTimeText = formatTime(new Date(`${todayDate}T${availableStartTime}`)); + const availableEndTimeText = formatTime(new Date(`${todayDate}T${availableEndTime}`)); + + const [{ name, description, startTime, endTime, password }, onChangeForm] = useInputs({ + name: reservation?.name ?? '', + description: reservation?.description ?? '', + startTime: initialStartTime, + endTime: initialEndTime, + password: '', + }); + + const startDateTime = new Date(`${date}T${startTime}Z`); + const endDateTime = new Date(`${date}T${endTime}Z`); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + if (!reservation) { + onCreateReservation({ + reservation: { + startDateTime, + endDateTime, + password, + name, + description, + }, + }); + + return; + } + + onEditReservation({ + reservation: { + startDateTime, + endDateTime, + name, + description, + }, + reservationId: reservation?.id, + }); + }; + + return ( + handleSubmit(event)}> + + + + + + + + + } + value={date} + min={formatDate(now)} + onChange={onChangeDate} + required + /> + + + + + + 예약 가능 시간 : {availableStartTimeText} ~ {availableEndTimeText} (최대{' '} + {formatTimePrettier(reservationMaximumTimeUnit)}) + + + {isEditMode || ( + + + + )} + + + + + + ); +}; + +export default ManagerReservationForm; diff --git a/frontend/src/pages/ManagerReservationEdit/ManagerReservationEdit.tsx b/frontend/src/pages/ManagerReservationEdit/ManagerReservationEdit.tsx deleted file mode 100644 index 1828a901e..000000000 --- a/frontend/src/pages/ManagerReservationEdit/ManagerReservationEdit.tsx +++ /dev/null @@ -1,205 +0,0 @@ -import { AxiosError } from 'axios'; -import { FormEventHandler } from 'react'; -import { useMutation } from 'react-query'; -import { useHistory, useLocation } from 'react-router-dom'; -import { putManagerReservation } from 'api/managerReservation'; -import { ReactComponent as CalendarIcon } from 'assets/svg/calendar.svg'; -import Button from 'components/Button/Button'; -import Header from 'components/Header/Header'; -import Input from 'components/Input/Input'; -import Layout from 'components/Layout/Layout'; -import PageHeader from 'components/PageHeader/PageHeader'; -import ReservationListItem from 'components/ReservationListItem/ReservationListItem'; -import MESSAGE from 'constants/message'; -import PATH from 'constants/path'; -import RESERVATION from 'constants/reservation'; -import useGuestReservations from 'hooks/useGuestReservations'; -import useInput from 'hooks/useInput'; -import useListenManagerMainState from 'hooks/useListenManagerMainState'; -import useManagerSpace from 'hooks/useManagerSpace'; -import { GuestMapState } from 'pages/GuestMap/GuestMap'; -import { Reservation } from 'types/common'; -import { ErrorResponse } from 'types/response'; -import { formatDate, formatTime, formatTimePrettier } from 'utils/datetime'; -import * as Styled from './ManagerReservationEdit.styles'; - -interface ManagerReservationEditState { - mapId: number; - spaceId: number; - reservation: Reservation; - selectedDate: string; -} - -const ManagerReservationEdit = (): JSX.Element => { - const location = useLocation(); - const history = useHistory(); - - const { mapId, spaceId, reservation, selectedDate } = location.state; - - if (!mapId || !spaceId || !reservation) history.replace(PATH.MANAGER_MAIN); - - useListenManagerMainState({ mapId: Number(mapId) }); - - const getSpace = useManagerSpace({ mapId, spaceId }); - const space = getSpace.data?.data.data; - - const availableStartTime = space?.settings?.availableStartTime ?? ''; - const availableEndTime = space?.settings?.availableEndTime ?? ''; - const reservationTimeUnit = space?.settings?.reservationTimeUnit ?? 0; - const reservationMaximumTimeUnit = space?.settings?.reservationMaximumTimeUnit ?? 0; - - const now = new Date(); - const todayDate = formatDate(new Date()); - - const [name, onChangeName] = useInput(reservation.name); - const [description, onChangeDescription] = useInput(reservation.description); - const [date, onChangeDate] = useInput(selectedDate); - const [startTime, onChangeStartTime] = useInput(formatTime(new Date(reservation.startDateTime))); - const [endTime, onChangeEndTime] = useInput(formatTime(new Date(reservation.endDateTime))); - - const startDateTime = new Date(`${date}T${startTime}Z`); - const endDateTime = new Date(`${date}T${endTime}Z`); - - const availableStartTimeText = formatTime(new Date(`${todayDate}T${availableStartTime}`)); - const availableEndTimeText = formatTime(new Date(`${todayDate}T${availableEndTime}`)); - - const getReservations = useGuestReservations({ mapId, spaceId, date }); - const reservations = getReservations.data?.data?.reservations ?? []; - - const editReservation = useMutation(putManagerReservation, { - onSuccess: () => { - history.push(PATH.MANAGER_MAIN, { - spaceId, - targetDate: new Date(`${date}T${startTime}`), - }); - }, - - onError: (error: AxiosError) => { - alert(error.response?.data.message ?? MESSAGE.RESERVATION.UNEXPECTED_ERROR); - }, - }); - - const handleSubmit: FormEventHandler = (event) => { - event.preventDefault(); - - if (editReservation.isLoading) return; - - const editReservationParams = { - name, - description, - startDateTime, - endDateTime, - }; - - editReservation.mutate({ - reservation: editReservationParams, - mapId, - spaceId, - reservationId: reservation.id, - }); - }; - - return ( - <> -
- - - - {space && ( - - - {space.name} - - )} - - - - - - - - } - value={date} - min={formatDate(now)} - onChange={onChangeDate} - required - /> - - - - - - {/* TODO 현재 NaN으로 표시 */} - {/* - 예약 가능 시간 : {availableStartTimeText} ~ {availableEndTimeText} (최대{' '} - {formatTimePrettier(reservationMaximumTimeUnit)}) - */} - - - - - {getReservations.isLoadingError && ( - - 예약 목록을 불러오는 데 문제가 생겼어요! -
- 새로 고침으로 다시 시도해주세요. -
- )} - {getReservations.isLoading && !getReservations.isLoadingError && ( - 불러오는 중입니다... - )} - {getReservations.isSuccess && reservations.length > 0 && ( - - {reservations?.map((reservation) => ( - - ))} - - )} -
- - - -
-
- - ); -}; - -export default ManagerReservationEdit; diff --git a/frontend/src/pages/ManagerSocialJoin/ManagerSocialJoin.styles.ts b/frontend/src/pages/ManagerSocialJoin/ManagerSocialJoin.styles.ts new file mode 100644 index 000000000..4f1e84796 --- /dev/null +++ b/frontend/src/pages/ManagerSocialJoin/ManagerSocialJoin.styles.ts @@ -0,0 +1,14 @@ +import styled from 'styled-components'; + +export const Container = styled.div` + width: 100%; + max-width: 660px; + margin: 0 auto; +`; + +export const PageTitle = styled.h2` + font-size: 1.5rem; + font-weight: 400; + text-align: center; + margin: 2.125rem auto; +`; diff --git a/frontend/src/pages/ManagerSocialJoin/ManagerSocialJoin.tsx b/frontend/src/pages/ManagerSocialJoin/ManagerSocialJoin.tsx new file mode 100644 index 000000000..a05d369c8 --- /dev/null +++ b/frontend/src/pages/ManagerSocialJoin/ManagerSocialJoin.tsx @@ -0,0 +1,63 @@ +import { AxiosError } from 'axios'; +import { useMutation } from 'react-query'; +import { useHistory, useLocation } from 'react-router-dom'; +import { postSocialJoin } from 'api/join'; +import Header from 'components/Header/Header'; +import Layout from 'components/Layout/Layout'; +import MESSAGE from 'constants/message'; +import PATH from 'constants/path'; +import { ErrorResponse } from 'types/response'; +import * as Styled from './ManagerSocialJoin.styles'; +import SocialJoinForm from './units/SocialJoinForm'; + +export interface SocialJoinParams { + email: string; + organization: string; +} + +interface SocialJoinState { + email: string; + oauthProvider: 'GITHUB' | 'GOOGLE'; +} + +const ManagerSocialJoin = (): JSX.Element => { + const history = useHistory(); + const location = useLocation(); + + const email = location.state?.email; + const oauthProvider = location.state?.oauthProvider; + + const socialJoin = useMutation(postSocialJoin, { + onSuccess: () => { + history.replace(PATH.MANAGER_LOGIN); + }, + + onError: (error: AxiosError) => { + alert(error?.response?.data.message ?? MESSAGE.JOIN.FAILURE); + }, + }); + + const handleSubmit = ({ email, organization }: SocialJoinParams) => { + if (!email || !organization || !oauthProvider || socialJoin.isLoading) return; + + socialJoin.mutate({ email, organization, oauthProvider }); + }; + + if (!email || !oauthProvider) { + history.replace(PATH.MANAGER_LOGIN); + } + + return ( + <> +
+ + + 추가 정보 입력 + + + + + ); +}; + +export default ManagerSocialJoin; diff --git a/frontend/src/pages/ManagerSocialJoin/units/SocialJoinForm.styles.ts b/frontend/src/pages/ManagerSocialJoin/units/SocialJoinForm.styles.ts new file mode 100644 index 000000000..32813ffb5 --- /dev/null +++ b/frontend/src/pages/ManagerSocialJoin/units/SocialJoinForm.styles.ts @@ -0,0 +1,9 @@ +import styled from 'styled-components'; + +export const Form = styled.form` + margin: 3.75rem 0 1rem; + + label { + margin-bottom: 3rem; + } +`; diff --git a/frontend/src/pages/ManagerSocialJoin/units/SocialJoinForm.tsx b/frontend/src/pages/ManagerSocialJoin/units/SocialJoinForm.tsx new file mode 100644 index 000000000..96c97c1f0 --- /dev/null +++ b/frontend/src/pages/ManagerSocialJoin/units/SocialJoinForm.tsx @@ -0,0 +1,70 @@ +import { FormEventHandler, useEffect, useState } from 'react'; +import Input from 'components/Input/Input'; +import SocialJoinButton from 'components/SocialAuthButton/SocialJoinButton'; +import MANAGER from 'constants/manager'; +import MESSAGE from 'constants/message'; +import REGEXP from 'constants/regexp'; +import useInput from 'hooks/useInput'; +import { SocialJoinParams } from '../ManagerSocialJoin'; +import * as Styled from './SocialJoinForm.styles'; + +interface Props { + email: string; + oauthProvider: 'GITHUB' | 'GOOGLE'; + onSubmit: ({ email, organization }: SocialJoinParams) => void; +} + +const SocialJoinForm = ({ email, oauthProvider, onSubmit }: Props): JSX.Element => { + const [organization, onChangeForm] = useInput(''); + + const [organizationMessage, setOrganizationMessage] = useState(''); + + const isValidOrganization = REGEXP.ORGANIZATION.test(organization); + + const handleSubmit: FormEventHandler = (event) => { + event.preventDefault(); + + onSubmit({ email, organization }); + }; + + useEffect(() => { + if (!organization) { + setOrganizationMessage(''); + + return; + } + + setOrganizationMessage( + isValidOrganization ? MESSAGE.JOIN.VALID_ORGANIZATION : MESSAGE.JOIN.INVALID_ORGANIZATION + ); + }, [organization, isValidOrganization]); + + return ( + + + + + + ); +}; + +export default SocialJoinForm; diff --git a/frontend/src/pages/ManagerSpaceEdit/ManagerSpaceEdit.styles.ts b/frontend/src/pages/ManagerSpaceEdit/ManagerSpaceEdit.styles.ts deleted file mode 100644 index 764210253..000000000 --- a/frontend/src/pages/ManagerSpaceEdit/ManagerSpaceEdit.styles.ts +++ /dev/null @@ -1,381 +0,0 @@ -import styled, { css } from 'styled-components'; -import Button from 'components/Button/Button'; -import IconButton from 'components/IconButton/IconButton'; -import { Z_INDEX } from 'constants/style'; -import { Color } from 'types/common'; - -interface ToolbarButtonProps { - selected?: boolean; -} - -interface ColorDotProps { - color: Color; - size: 'medium' | 'large'; -} - -interface WeekdayProps { - value?: 'Saturday' | 'Sunday'; -} - -interface BoardContainerProps { - isDraggable?: boolean; - isDragging?: boolean; -} - -interface FormContainerProps { - disabled: boolean; -} - -interface SpaceAreaRectProps { - disabled: boolean; -} - -export const Page = styled.div` - padding: 2rem 0; - display: flex; - flex-direction: column; - gap: 1rem; - height: calc(100vh - 3rem); -`; - -export const EditorHeader = styled.div` - display: flex; - justify-content: space-between; - align-items: center; -`; - -export const MapName = styled.h3` - font-size: 1.5rem; -`; - -export const ButtonContainer = styled.div``; - -export const EditorContainer = styled.div` - flex: 1; - display: flex; - justify-content: space-between; - gap: 3rem; - overflow: hidden; -`; - -export const EditorContent = styled.div` - position: relative; - display: flex; - flex: 2; - height: 100%; - border: 1px solid ${({ theme }) => theme.gray[300]}; - border-radius: 0.25rem; -`; - -export const Editor = styled.div` - flex: 1; - height: 100%; -`; - -export const FormContainer = styled.div` - position: relative; - display: flex; - flex-direction: column; - flex: 1; - height: 100%; - min-width: 20rem; - border: 1px solid ${({ theme }) => theme.gray[300]}; - border-radius: 0.25rem; - - &::before { - ${({ disabled }) => (disabled ? `content: ''` : '')}; - position: absolute; - top: 0; - left: 0; - display: block; - width: 100%; - height: 100%; - background-color: ${({ theme }) => theme.gray[200]}; - opacity: 0.3; - z-index: ${Z_INDEX.MODAL_OVERLAY}; - } -`; - -export const Form = styled.form` - padding: 2rem 1.5rem; - overflow-y: ${({ disabled }) => (disabled ? 'hidden' : 'auto')}; - flex: 1; -`; - -export const FormHeader = styled.h4` - font-size: 1.5rem; - margin-bottom: 1.625rem; -`; - -export const FormRow = styled.div` - margin: 2rem 0; - position: relative; - - &:last-of-type { - margin-bottom: 0; - } -`; - -export const FormLabel = styled.div` - font-size: 0.75rem; - color: ${({ theme }) => theme.gray[500]}; -`; - -export const FormSubmitContainer = styled.div` - display: flex; - justify-content: flex-end; -`; - -export const SpaceSettingHeader = styled.div` - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 1.75rem; - - ${FormHeader} { - margin: 0; - } -`; - -export const SpaceSelect = styled.div` - border-bottom: 1px solid ${({ theme }) => theme.gray[300]}; - padding: 1.5rem; - position: relative; -`; - -export const SpaceSelectWrapper = styled.div` - margin-bottom: 0.5rem; -`; - -export const SpaceOption = styled.div` - display: flex; - align-items: center; - gap: 0.75rem; -`; - -export const AddButtonWrapper = styled.div` - display: inline-block; - position: absolute; - right: 1.5rem; - bottom: -1.25rem; - z-index: ${Z_INDEX.SPACE_ADD_BUTTON}; -`; - -export const ColorSelect = styled.div` - display: flex; - justify-content: space-between; - align-items: center; - margin: 1.5rem 0; -`; - -export const PresetSelect = styled.div` - display: flex; - gap: 0.5rem; -`; - -export const PresetSelectWrapper = styled.div` - flex: 1; -`; - -export const PresetOption = styled.div` - display: flex; - justify-content: space-between; - align-items: center; -`; - -export const PresetName = styled.span` - display: block; - flex: 1; -`; - -export const PresetNameFormControl = styled.div` - display: flex; - justify-content: flex-end; - margin-top: 1rem; -`; - -export const InputWrapper = styled.div` - display: flex; - gap: 1rem; - - label { - flex: 1; - } -`; - -export const InputMessage = styled.p` - font-size: 0.75rem; - margin: 0.25rem 0.75rem; - color: ${({ theme }) => theme.gray[500]}; -`; - -export const WeekdaySelect = styled.div` - display: flex; - justify-content: space-between; - align-items: center; -`; - -export const WeekdayLabel = styled.label` - display: inline-flex; - flex-direction: column; - align-items: center; - cursor: pointer; -`; - -const weekdayColorCSS = { - default: css``, - Saturday: css` - color: ${({ theme }) => theme.blue[900]}; - `, - Sunday: css` - color: ${({ theme }) => theme.red[500]}; - `, -}; - -export const Weekday = styled.span` - ${({ value }) => weekdayColorCSS[value ?? 'default']}; -`; - -export const ColorDotButton = styled.button` - background: none; - border: none; - padding: 0; - cursor: pointer; -`; - -const colorDotSizeCSS = { - medium: css` - width: 1rem; - height: 1rem; - `, - large: css` - width: 1.5rem; - height: 1.5rem; - `, -}; - -export const ColorDot = styled.span` - display: inline-block; - background-color: ${({ color }) => color}; - border-radius: 50%; - ${({ size }) => colorDotSizeCSS[size]}; -`; - -export const ColorInputLabel = styled.label` - display: inline-block; - padding: 0.5rem; - border: 1px solid ${({ theme }) => theme.gray[500]}; - border-radius: 0.125rem; - cursor: pointer; -`; - -export const ColorInput = styled.input` - width: 0; - height: 0; - opacity: 0; - padding: 0; -`; - -export const RadioLabel = styled.label``; - -export const RadioInput = styled.input``; - -export const RadioLabelText = styled.span``; - -export const Fieldset = styled.div` - border: 1px solid ${({ theme }) => theme.gray[500]}; - border-radius: 0.125rem; - padding: 1rem 0.75rem; - - ${FormLabel} { - position: absolute; - top: -0.375rem; - left: 0.75rem; - padding: 0 0.25rem; - background-color: ${({ theme }) => theme.white}; - } -`; - -export const DeleteButton = styled(Button)` - color: ${({ theme }) => theme.red[900]}; - display: inline-flex; - align-items: center; - gap: 0.25rem; - - svg { - width: 1rem; - height: 1rem; - vertical-align: middle; - fill: ${({ theme }) => theme.red[900]}; - } -`; - -export const BoardContainer = styled.svg` - cursor: ${({ isDraggable, isDragging }) => { - if (isDraggable) { - if (isDragging) return 'grabbing'; - else return 'grab'; - } - return 'default'; - }}; -`; - -export const Toolbar = styled.div` - padding: 1rem 0.5rem; - background-color: ${({ theme }) => theme.gray[100]}; - border-right: 1px solid ${({ theme }) => theme.gray[300]}; - display: flex; - flex-direction: column; - gap: 1rem; -`; - -const primaryIconCSS = css` - svg { - fill: ${({ theme }) => theme.primary[400]}; - } -`; - -export const ToolbarButton = styled(IconButton)` - background-color: ${({ theme, selected }) => (selected ? theme.gray[100] : 'none')}; - border: 1px solid ${({ theme, selected }) => (selected ? theme.gray[400] : 'transparent')}; - border-radius: 0; - box-sizing: content-box; - ${({ selected }) => selected && primaryIconCSS} -`; - -export const SpaceShapeSelect = styled.div` - position: absolute; - top: 0.5rem; - left: 50%; - transform: translateX(-50%); - margin: 0 auto; - padding: 0.5rem 1rem; - background-color: ${({ theme }) => theme.gray[100]}; - border-right: 1px solid ${({ theme }) => theme.gray[300]}; - display: flex; - gap: 1rem; -`; - -export const NoSpaceMessage = styled.p` - text-align: center; - font-size: 1.125rem; - margin: 3rem auto; -`; - -export const SpaceAreaRect = styled.rect` - opacity: 0.3; - cursor: ${({ disabled }) => (disabled ? 'default' : 'pointer')}; - - &:hover { - opacity: ${({ disabled }) => (disabled ? '0.3' : '0.2')}; - } -`; - -export const SpaceAreaText = styled.text` - dominant-baseline: middle; - text-anchor: middle; - fill: ${({ theme }) => theme.black[700]}; - font-size: 1rem; - pointer-events: none; - user-select: none; -`; diff --git a/frontend/src/pages/ManagerSpaceEdit/ManagerSpaceEdit.tsx b/frontend/src/pages/ManagerSpaceEdit/ManagerSpaceEdit.tsx deleted file mode 100644 index 585e4bf07..000000000 --- a/frontend/src/pages/ManagerSpaceEdit/ManagerSpaceEdit.tsx +++ /dev/null @@ -1,1480 +0,0 @@ -import { AxiosError } from 'axios'; -import { - ChangeEventHandler, - FormEventHandler, - MouseEventHandler, - useCallback, - useEffect, - useMemo, - useRef, - useState, - WheelEventHandler, -} from 'react'; -import { useMutation } from 'react-query'; -import { Link, Redirect, useParams } from 'react-router-dom'; -import { deleteManagerSpace, postManagerSpace, putManagerSpace } from 'api/managerSpace'; -import { deletePreset, postPreset } from 'api/presets'; -import { ReactComponent as CloseIcon } from 'assets/svg/close.svg'; -import { ReactComponent as DeleteIcon } from 'assets/svg/delete.svg'; -import { ReactComponent as PaletteIcon } from 'assets/svg/palette.svg'; -import { ReactComponent as PlusSmallIcon } from 'assets/svg/plus-small.svg'; -import { ReactComponent as PolygonIcon } from 'assets/svg/polygon.svg'; -import { ReactComponent as RectIcon } from 'assets/svg/rect.svg'; -import Button from 'components/Button/Button'; -import Header from 'components/Header/Header'; -import IconButton from 'components/IconButton/IconButton'; -import Input from 'components/Input/Input'; -import Layout from 'components/Layout/Layout'; -import Modal from 'components/Modal/Modal'; -import Select from 'components/Select/Select'; -import Toggle from 'components/Toggle/Toggle'; -import { DrawingAreaShape, EDITOR, KEY } from 'constants/editor'; -import MESSAGE from 'constants/message'; -import PALETTE from 'constants/palette'; -import PATH from 'constants/path'; -import SPACE from 'constants/space'; -import useInput from 'hooks/useInput'; -import useListenManagerMainState from 'hooks/useListenManagerMainState'; -import useManagerMap from 'hooks/useManagerMap'; -import useManagerSpaces from 'hooks/useManagerSpaces'; -import usePresets from 'hooks/usePreset'; -import useToggle from 'hooks/useToggle'; -import { - Coordinate, - DrawingStatus, - ManagerSpace, - MapDrawing, - Preset, - SpaceArea, -} from 'types/common'; -import { ErrorResponse } from 'types/response'; -import { formatDate, formatTimeWithSecond } from 'utils/datetime'; -import * as Styled from './ManagerSpaceEdit.styles'; - -const colorSelectOptions = [ - PALETTE.RED[500], - PALETTE.ORANGE[500], - PALETTE.YELLOW[500], - PALETTE.GREEN[500], - PALETTE.BLUE[300], - PALETTE.BLUE[900], - PALETTE.PURPLE[500], -]; - -interface Params { - mapId: string; -} - -interface CreateResponseHeaders { - location: string; -} - -const ManagerSpaceEdit = (): JSX.Element => { - const editorRef = useRef(null); - const spaceNameRef = useRef(null); - const { mapId } = useParams(); - - useListenManagerMainState({ mapId: Number(mapId) }); - - const todayDate = formatDate(new Date()); - const initialStartTime = formatTimeWithSecond(new Date(`${todayDate}T07:00:00`)); - const initialEndTime = formatTimeWithSecond(new Date(`${todayDate}T23:00:00`)); - - const map = useManagerMap({ mapId: Number(mapId) }); - const mapName = map.data?.data.mapName ?? ''; - const { width, height, mapElements } = useMemo(() => { - try { - return JSON.parse(map.data?.data.mapDrawing ?? '{}') as MapDrawing; - } catch (error) { - alert(MESSAGE.MANAGER_SPACE.GET_UNEXPECTED_ERROR); - - return { width: 800, height: 600, mapElements: [] }; - } - }, [map.data?.data.mapDrawing]); - - const managerSpaces = useManagerSpaces({ mapId: Number(mapId) }); - const spaces: ManagerSpace[] = useMemo( - () => - managerSpaces.data?.data.spaces.map((space) => ({ - ...space, - area: JSON.parse(space.area) as SpaceArea, - })) ?? [], - [managerSpaces.data?.data.spaces] - ); - const spaceOptions = spaces.map(({ id, name, color }) => ({ - value: `${id}`, - children: ( - - - {name} - - ), - })); - - const createSpace = useMutation(postManagerSpace, { - onSuccess: (response) => { - const { location } = response.headers as CreateResponseHeaders; - const newSpaceId = Number(location.split('/').pop() ?? ''); - - initializeDrawingStatus(); - setAddingSpace(false); - setSelectedSpaceId(newSpaceId); - - managerSpaces.refetch(); - alert(MESSAGE.MANAGER_SPACE.SPACE_CREATED); - }, - onError: (error: AxiosError) => { - alert(error.response?.data.message ?? MESSAGE.MANAGER_SPACE.ADD_UNEXPECTED_ERROR); - }, - }); - - const updateSpace = useMutation(putManagerSpace, { - onSuccess: () => { - managerSpaces.refetch(); - alert(MESSAGE.MANAGER_SPACE.SPACE_SETTING_UPDATED); - }, - onError: (error: AxiosError) => { - alert(error.response?.data.message ?? MESSAGE.MANAGER_SPACE.EDIT_UNEXPECTED_ERROR); - }, - }); - - const deleteSpace = useMutation(deleteManagerSpace, { - onSuccess: () => { - setSelectedSpaceId(null); - setArea(null); - - managerSpaces.refetch(); - alert(MESSAGE.MANAGER_SPACE.SPACE_DELETED); - }, - onError: (error: AxiosError) => { - alert(error.response?.data.message ?? MESSAGE.MANAGER_SPACE.DELETE_UNEXPECTED_ERROR); - }, - }); - - const getPresets = usePresets(); - const presets = useMemo( - () => getPresets.data?.data?.presets ?? [], - [getPresets.data?.data?.presets] - ); - - const createPreset = useMutation(postPreset, { - onSuccess: (response) => { - const { location } = response.headers as CreateResponseHeaders; - const newPresetId = Number(location.split('/').pop() ?? ''); - - setSelectedPresetId(newPresetId); - setPresetFormOpen(false); - setPresetName(''); - - getPresets.refetch(); - }, - onError: (error: AxiosError) => { - alert(error.response?.data.message ?? MESSAGE.MANAGER_SPACE.ADD_PRESET_UNEXPECTED_ERROR); - }, - }); - - const removePreset = useMutation(deletePreset, { - onError: (error: AxiosError) => { - alert(error.response?.data.message ?? MESSAGE.MANAGER_SPACE.DELETE_PRESET_UNEXPECTED_ERROR); - }, - }); - - const [isPresetFormOpen, setPresetFormOpen] = useState(false); - const [presetName, onChangePresetName, setPresetName] = useInput(''); - - const [isDragging, setDragging] = useState(false); - const [isDraggable, setDraggable] = useState(false); - const [dragOffsetX, setDragOffsetX] = useState(0); - const [dragOffsetY, setDragOffsetY] = useState(0); - - const [selectedSpaceId, setSelectedSpaceId] = useState(null); - const [selectedPresetId, setSelectedPresetId] = useState(null); - - const [isDrawingArea, setDrawingArea] = useState(false); - const [isAddingSpace, setAddingSpace] = useState(false); - const [drawingAreaShape, setDrawingAreaShape] = useState(DrawingAreaShape.RECT); - const [area, setArea] = useState(null); - - const [spaceName, onChangeSpaceName, setSpaceName] = useInput(''); - const [reservationEnable, onChangeReservationEnable, setReservationEnable] = useToggle(true); - const [spaceColor, onChangeSpaceColor, setSpaceColor] = useInput(PALETTE.RED[500]); - const [availableStartTime, onChangeAvailableStartTime, setAvailableStartTime] = - useInput(initialStartTime); - const [availableEndTime, onChangeAvailableEndTime, setAvailableEndTime] = - useInput(initialEndTime); - const [reservationTimeUnit, onChangeReservationTimeUnit, setReservationTimeUnit] = useInput('10'); - const [ - reservationMinimumTimeUnit, - onChangeReservationMinimumTimeUnit, - setReservationMinimumTimeUnit, - ] = useInput('10'); - const [ - reservationMaximumTimeUnit, - onChangeReservationMaximumTimeUnit, - setReservationMaximumTimeUnit, - ] = useInput('1440'); - const [enabledWeekdays, setEnabledWeekdays] = useState({ - monday: true, - tuesday: true, - wednesday: true, - thursday: true, - friday: true, - saturday: true, - sunday: true, - }); - const { monday, tuesday, wednesday, thursday, friday, saturday, sunday } = enabledWeekdays; - - const enabledDayOfWeek = Object.entries(enabledWeekdays) - .filter(([, checked]) => checked) - .map(([weekday]) => weekday) - .join(','); - - const [board, setBoard] = useState({ - width: 0, - height: 0, - x: 0, - y: 0, - scale: 1, - }); - - const [coordinate, setCoordinate] = useState({ - x: -EDITOR.GRID_SIZE, - y: -EDITOR.GRID_SIZE, - }); - const stickyCoordinate: Coordinate = useMemo( - () => ({ - x: Math.floor(coordinate.x / EDITOR.GRID_SIZE) * EDITOR.GRID_SIZE, - y: Math.floor(coordinate.y / EDITOR.GRID_SIZE) * EDITOR.GRID_SIZE, - }), - [coordinate] - ); - - const [drawingStatus, setDrawingStatus] = useState({}); - const [guideArea, setGuideArea] = useState({ - shape: DrawingAreaShape.RECT, - x: 0, - y: 0, - width: 0, - height: 0, - }); - - const handleWheel: WheelEventHandler = useCallback((event) => { - const { offsetX, offsetY, deltaY } = event.nativeEvent; - - setBoard((prevState) => { - const { scale, x, y, width, height } = prevState; - - const nextScale = scale - deltaY * EDITOR.SCALE_DELTA; - - if (nextScale <= EDITOR.MIN_SCALE || nextScale >= EDITOR.MAX_SCALE) { - return { - ...prevState, - scale: prevState.scale, - }; - } - - const cursorX = (offsetX - x) / (width * scale); - const cursorY = (offsetY - y) / (height * scale); - - const widthDiff = Math.abs(width * nextScale - width * scale) * cursorX; - const heightDiff = Math.abs(height * nextScale - height * scale) * cursorY; - - const nextX = nextScale > scale ? x - widthDiff : x + widthDiff; - const nextY = nextScale > scale ? y - heightDiff : y + heightDiff; - - return { - ...prevState, - x: nextX, - y: nextY, - scale: nextScale, - }; - }); - }, []); - - const handleDragStart: MouseEventHandler = useCallback( - (event) => { - if (isDrawingArea || !isDraggable) return; - - setDragOffsetX(event.nativeEvent.offsetX - board.x); - setDragOffsetY(event.nativeEvent.offsetY - board.y); - - setDragging(true); - }, - [board.x, board.y, isDraggable, isDrawingArea] - ); - - const handleDragEnd = useCallback(() => { - setDragOffsetX(0); - setDragOffsetY(0); - - setDragging(false); - }, []); - - const handleDrag: MouseEventHandler = useCallback( - (event) => { - if (isDrawingArea || !isDragging || !isDraggable) return; - - const { offsetX, offsetY } = event.nativeEvent; - - setBoard((prevState) => ({ - ...prevState, - x: offsetX - dragOffsetX, - y: offsetY - dragOffsetY, - })); - }, - [dragOffsetX, dragOffsetY, isDraggable, isDragging, isDrawingArea] - ); - - const handleMouseOut = useCallback(() => { - setDraggable(false); - setDragging(false); - }, []); - - const getSVGCoordinate = useCallback( - (event: React.MouseEvent) => { - const svg = (event.nativeEvent.target as SVGElement)?.ownerSVGElement; - if (!svg) return { svg: null, x: -1, y: -1 }; - - let point = svg.createSVGPoint(); - - point.x = event.nativeEvent.clientX; - point.y = event.nativeEvent.clientY; - point = point.matrixTransform(svg.getScreenCTM()?.inverse()); - - const x = (point.x - board.x) * (1 / board.scale); - const y = (point.y - board.y) * (1 / board.scale); - - return { svg, x, y }; - }, - [board.scale, board.x, board.y] - ); - - const initializeDrawingStatus = useCallback(() => { - setDrawingStatus({}); - setGuideArea({ shape: DrawingAreaShape.RECT, x: 0, y: 0, width: 0, height: 0 }); - }, []); - - const initializeSpaceAddForm = useCallback(() => { - setSpaceName(''); - setSpaceColor(PALETTE.RED[500]); - setAvailableStartTime(initialStartTime); - setAvailableEndTime(initialEndTime); - setReservationTimeUnit('10'); - setReservationMinimumTimeUnit('10'); - setReservationMaximumTimeUnit('1440'); - setReservationEnable(true); - setEnabledWeekdays({ - monday: true, - tuesday: true, - wednesday: true, - thursday: true, - friday: true, - saturday: true, - sunday: true, - }); - }, [ - initialEndTime, - initialStartTime, - setAvailableEndTime, - setAvailableStartTime, - setReservationEnable, - setReservationMaximumTimeUnit, - setReservationMinimumTimeUnit, - setReservationTimeUnit, - setSpaceName, - setSpaceColor, - ]); - - const handleChangeReservationForm = ( - event: React.ChangeEvent, - callback: ChangeEventHandler - ) => { - if (selectedPresetId) selectPreset(null); - - callback(event); - }; - - const handleSubmitPreset: FormEventHandler = (event) => { - event.preventDefault(); - - const availableTime = { - start: formatTimeWithSecond(new Date(`${todayDate}T${availableStartTime}`)), - end: formatTimeWithSecond(new Date(`${todayDate}T${availableEndTime}`)), - }; - - const settingsRequest = { - availableStartTime: availableTime.start, - availableEndTime: availableTime.end, - reservationTimeUnit: Number(reservationTimeUnit), - reservationMinimumTimeUnit: Number(reservationMinimumTimeUnit), - reservationMaximumTimeUnit: Number(reservationMaximumTimeUnit), - reservationEnable, - enabledDayOfWeek, - }; - - createPreset.mutate({ name: presetName, settingsRequest }); - }; - - const handleAddPreset = () => { - setPresetFormOpen(true); - }; - - const handleDeletePreset = (event: React.MouseEvent, id: Preset['id']) => { - event.stopPropagation(); - if (!window.confirm(MESSAGE.MANAGER_SPACE.DELETE_PRESET_CONFIRM)) return; - - removePreset.mutate({ id }); - }; - - const selectPreset = useCallback( - (id: number | null) => { - setSelectedPresetId(id); - - if (id === null) return; - - const { - availableStartTime, - availableEndTime, - reservationTimeUnit, - reservationMinimumTimeUnit, - reservationMaximumTimeUnit, - enabledDayOfWeek, - } = presets.find((preset) => preset.id === id) ?? presets[0]; - - const enableWeekdays = enabledDayOfWeek?.toLowerCase()?.split(',') ?? []; - - setAvailableStartTime(availableStartTime); - setAvailableEndTime(availableEndTime); - setReservationTimeUnit(`${reservationTimeUnit}`); - setReservationMinimumTimeUnit(`${reservationMinimumTimeUnit}`); - setReservationMaximumTimeUnit(`${reservationMaximumTimeUnit}`); - setEnabledWeekdays({ - monday: enableWeekdays.includes('monday'), - tuesday: enableWeekdays.includes('tuesday'), - wednesday: enableWeekdays.includes('wednesday'), - thursday: enableWeekdays.includes('thursday'), - friday: enableWeekdays.includes('friday'), - saturday: enableWeekdays.includes('saturday'), - sunday: enableWeekdays.includes('sunday'), - }); - }, - [ - presets, - setAvailableEndTime, - setAvailableStartTime, - setReservationMaximumTimeUnit, - setReservationMinimumTimeUnit, - setReservationTimeUnit, - ] - ); - - const selectSpace = useCallback( - (id: number | null) => { - selectPreset(null); - setSelectedSpaceId(id); - - if (id === null) return; - - const selectedSpace = spaces.find((space) => space.id === id); - if (!selectedSpace?.id) return; - - const { name, color, area, settings } = selectedSpace; - const { - availableStartTime, - availableEndTime, - reservationTimeUnit, - reservationMinimumTimeUnit, - reservationMaximumTimeUnit, - reservationEnable, - enabledDayOfWeek, - } = settings; - - const enableWeekdays = enabledDayOfWeek?.toLowerCase()?.split(',') ?? []; - - setArea(area); - setSpaceName(name); - setSpaceColor(color ?? PALETTE.RED[500]); - setAvailableStartTime(availableStartTime); - setAvailableEndTime(availableEndTime); - setReservationTimeUnit(`${reservationTimeUnit}`); - setReservationMinimumTimeUnit(`${reservationMinimumTimeUnit}`); - setReservationMaximumTimeUnit(`${reservationMaximumTimeUnit}`); - setReservationEnable(reservationEnable); - setEnabledWeekdays({ - monday: enableWeekdays.includes('monday'), - tuesday: enableWeekdays.includes('tuesday'), - wednesday: enableWeekdays.includes('wednesday'), - thursday: enableWeekdays.includes('thursday'), - friday: enableWeekdays.includes('friday'), - saturday: enableWeekdays.includes('saturday'), - sunday: enableWeekdays.includes('sunday'), - }); - }, - [ - selectPreset, - setAvailableEndTime, - setAvailableStartTime, - setReservationEnable, - setReservationMaximumTimeUnit, - setReservationMinimumTimeUnit, - setReservationTimeUnit, - setSpaceColor, - setSpaceName, - spaces, - ] - ); - - const handleMouseMove: MouseEventHandler = useCallback( - (event) => { - const { x, y } = getSVGCoordinate(event); - setCoordinate({ x, y }); - - if (!drawingStatus.start) return; - - const [startX, endX] = - drawingStatus.start.x > stickyCoordinate.x - ? [stickyCoordinate.x, drawingStatus.start.x] - : [drawingStatus.start.x, stickyCoordinate.x]; - const [startY, endY] = - drawingStatus.start.y > stickyCoordinate.y - ? [stickyCoordinate.y, drawingStatus.start.y] - : [drawingStatus.start.y, stickyCoordinate.y]; - - const width = Math.abs(startX - endX) + EDITOR.GRID_SIZE; - const height = Math.abs(startY - endY) + EDITOR.GRID_SIZE; - - setGuideArea({ - shape: drawingAreaShape, - x: startX, - y: startY, - width, - height, - }); - }, - [ - drawingAreaShape, - drawingStatus.start, - getSVGCoordinate, - stickyCoordinate.x, - stickyCoordinate.y, - ] - ); - - const handleDrawStart: MouseEventHandler = useCallback(() => { - if (!isDrawingArea || !drawingAreaShape) return; - - setDrawingStatus((prevDrawingStatus) => ({ - ...prevDrawingStatus, - start: stickyCoordinate, - })); - }, [drawingAreaShape, isDrawingArea, stickyCoordinate]); - - const handleDrawEnd: MouseEventHandler = useCallback(() => { - if (!isDrawingArea || !drawingAreaShape) return; - if (!drawingStatus || !drawingStatus.start) return; - - const [startX, endX] = - drawingStatus.start.x > stickyCoordinate.x - ? [stickyCoordinate.x, drawingStatus.start.x] - : [drawingStatus.start.x, stickyCoordinate.x]; - const [startY, endY] = - drawingStatus.start.y > stickyCoordinate.y - ? [stickyCoordinate.y, drawingStatus.start.y] - : [drawingStatus.start.y, stickyCoordinate.y]; - - const width = Math.abs(startX - endX) + EDITOR.GRID_SIZE; - const height = Math.abs(startY - endY) + EDITOR.GRID_SIZE; - - setArea({ - shape: DrawingAreaShape.RECT, - x: startX, - y: startY, - width, - height, - }); - - initializeSpaceAddForm(); - initializeDrawingStatus(); - setDrawingArea(false); - - spaceNameRef.current?.focus(); - }, [ - drawingAreaShape, - drawingStatus, - initializeDrawingStatus, - initializeSpaceAddForm, - isDrawingArea, - stickyCoordinate.x, - stickyCoordinate.y, - ]); - - const handleChangeEnabledDayOfWeek: ChangeEventHandler = useCallback( - (event) => { - const { name, checked } = event.target; - - setEnabledWeekdays((prevDayOfWeek) => ({ - ...prevDayOfWeek, - [name]: checked, - })); - }, - [] - ); - - const handleCancelForm = useCallback(() => { - if (!window.confirm(MESSAGE.MANAGER_SPACE.CANCEL_ADD_SPACE_CONFIRM)) return; - if (!selectedSpaceId) setArea(null); - - initializeDrawingStatus(); - setAddingSpace(false); - selectSpace(selectedSpaceId); - }, [initializeDrawingStatus, selectSpace, selectedSpaceId]); - - const handleCancelAddingSpace = useCallback(() => { - if (selectedSpaceId) selectSpace(selectedSpaceId); - - initializeDrawingStatus(); - setAddingSpace(false); - setDrawingArea(false); - }, [initializeDrawingStatus, selectSpace, selectedSpaceId]); - - const handleDeleteSpace = useCallback(() => { - if (!selectedSpaceId) return; - if (!window.confirm(MESSAGE.MANAGER_SPACE.DELETE_SPACE_CONFIRM)) return; - - const mapImageSvg = ` - - ${spaces - .filter(({ id }) => id !== selectedSpaceId) - .map( - ({ color, area }) => ` - - - - ` - ) - .join('')} - ${mapElements - ?.map( - ({ points, stroke }) => ` - - ` - ) - .join('')} - -` - .replace(/(\r\n\t|\n|\r\t|\s{1,})/gm, ' ') - .replace(/\s{2,}/g, ' '); - - deleteSpace.mutate({ - mapId: Number(mapId), - spaceId: selectedSpaceId, - mapImageSvg, - }); - }, [deleteSpace, height, mapElements, mapId, selectedSpaceId, spaces, width]); - - const handleAddSpace = useCallback(() => { - setArea(null); - initializeDrawingStatus(); - setAddingSpace(true); - setDrawingArea(true); - }, [initializeDrawingStatus]); - - const handleClickSpaceArea = useCallback( - (id) => { - if (isAddingSpace) return; - - selectSpace(id); - }, - [selectSpace, isAddingSpace] - ); - - const handleSubmitSpace: FormEventHandler = useCallback( - (event) => { - event.preventDefault(); - - const availableTime = { - start: formatTimeWithSecond(new Date(`${todayDate}T${availableStartTime}`)), - end: formatTimeWithSecond(new Date(`${todayDate}T${availableEndTime}`)), - }; - - const targetSpaces = - selectedSpaceId && !isAddingSpace - ? spaces.filter(({ id }) => selectedSpaceId !== id) - : spaces; - - const mapImageSvg = ` - - ${ - area - ? ` - - - - ` - : '' - } - - ${targetSpaces - .map( - ({ color, area }) => ` - - - - ` - ) - .join('')} - - ${mapElements - .map((element) => - element.type === 'polyline' - ? ` - - ` - : ` - - ` - ) - .join('')} - - ` - .replace(/(\r\n\t|\n|\r\t|\s{1,})/gm, ' ') - .replace(/\s{2,}/g, ' '); - - if (isAddingSpace) { - createSpace.mutate({ - mapId: Number(mapId), - space: { - name: spaceName, - color: spaceColor, - description: spaceName, - area: JSON.stringify(area), - settingsRequest: { - availableStartTime: availableTime.start, - availableEndTime: availableTime.end, - reservationTimeUnit: Number(reservationTimeUnit), - reservationMinimumTimeUnit: Number(reservationMinimumTimeUnit), - reservationMaximumTimeUnit: Number(reservationMaximumTimeUnit), - reservationEnable, - enabledDayOfWeek, - }, - mapImageSvg, - }, - }); - - return; - } - - if (selectedSpaceId && !isAddingSpace) { - updateSpace.mutate({ - mapId: Number(mapId), - spaceId: selectedSpaceId, - space: { - name: spaceName, - color: spaceColor, - description: spaceName, - area: JSON.stringify(area), - settingsRequest: { - availableStartTime: availableTime.start, - availableEndTime: availableTime.end, - reservationTimeUnit: Number(reservationTimeUnit), - reservationMinimumTimeUnit: Number(reservationMinimumTimeUnit), - reservationMaximumTimeUnit: Number(reservationMaximumTimeUnit), - reservationEnable, - enabledDayOfWeek, - }, - mapImageSvg, - }, - }); - } - }, - [ - area, - availableEndTime, - availableStartTime, - createSpace, - enabledDayOfWeek, - height, - isAddingSpace, - mapElements, - mapId, - reservationEnable, - reservationMaximumTimeUnit, - reservationMinimumTimeUnit, - reservationTimeUnit, - selectedSpaceId, - spaceColor, - spaceName, - spaces, - todayDate, - updateSpace, - width, - ] - ); - - const handleKeyDown = useCallback((event: KeyboardEvent) => { - if ((event.target as HTMLElement).tagName === 'INPUT') return; - - if (event.key === KEY.SPACE) setDraggable(true); - }, []); - - const handleKeyUp = useCallback((event: KeyboardEvent) => { - if (event.key === KEY.SPACE) setDraggable(false); - }, []); - - const presetOptions = presets.map(({ id, name }) => ({ - value: `${id}`, - title: name, - children: ( - - {name} - handleDeletePreset(event, id)}> - - - - ), - })); - - useEffect(() => { - const editorWidth = editorRef.current ? editorRef.current.offsetWidth : 0; - const editorHeight = editorRef.current ? editorRef.current.offsetHeight : 0; - - setBoard((prevState) => ({ - ...prevState, - x: (editorWidth - board.width) / 2, - y: (editorHeight - board.height) / 2, - })); - }, [board.width, board.height]); - - useEffect(() => { - if (width && height) { - setBoard((prevBoard) => ({ - ...prevBoard, - width, - height, - })); - } - }, [width, height]); - - useEffect(() => { - document.addEventListener('keydown', handleKeyDown); - document.addEventListener('keyup', handleKeyUp); - - return () => { - document.removeEventListener('keydown', handleKeyDown); - document.removeEventListener('keyup', handleKeyUp); - }; - }, [handleKeyDown, handleKeyUp]); - - if ( - (map.isError && map.error?.response?.status === 401) || - (managerSpaces.isError && managerSpaces.error?.response?.status === 401) - ) { - return ; - } - - if (map.isError || managerSpaces.isError) { - return ; - } - - return ( - <> -
- - - - {mapName} - - - - - - - - - {isDrawingArea && ( - - - - - setDrawingAreaShape(DrawingAreaShape.RECT)} - > - - - - {/* NOTE 추후 다각형 기능 구현 시, 이 부분의 주석을 해제하고 작성하면 됩니다. */} - {/* setDrawingAreaShape(DrawingAreaShape.POLYGON)} - > - - */} - - )} - - - - - - - - - - - - - - - - - {/* Note: 새로 그려지는 중인 공간의 영역 */} - {guideArea && ( - - )} - - {/* Note: 커서 위치 표시 */} - {isDrawingArea && ( - - )} - - {/* Note: 모눈 표시 */} - - - {/* Note: 현재 추가 혹은 삭제 중인 공간의 영역 */} - {area && ( - - {area.shape === DrawingAreaShape.RECT && ( - - )} - - - {spaceName} - - - )} - - {/* Note: 공간 영역 */} - {spaces.map(({ id, color, area, name }) => ( - - handleClickSpaceArea(id)} - disabled={isAddingSpace || id === selectedSpaceId} - /> - {(isAddingSpace || id !== selectedSpaceId) && ( - - {name} - - )} - - ))} - - {/* Note: 맵 요소 */} - {mapElements?.map((element) => - element.type === 'polyline' ? ( - - ) : ( - - ) - )} - - - - - - - - 공간 선택 - - } - label="공간 이름" - value={spaceName} - onChange={onChangeSpaceName} - ref={spaceNameRef} - required - /> - - - - - - - - {colorSelectOptions.map((color) => ( - setSpaceColor(color)} - > - - - ))} - - - 예약 조건 - {/* Note: 프리셋 기능을 구현할 때 이 UI를 활성화하면 됩니다 */} - - - - - handleChangeReservationForm(event, onChangeAvailableStartTime) - } - required - /> - - handleChangeReservationForm(event, onChangeAvailableEndTime) - } - required - /> - - - 예약이 열리는 시간과 닫히는 시간을 설정할 수 있습니다. - - - - - - 예약 시간 단위 - - - - handleChangeReservationForm(event, onChangeReservationTimeUnit) - } - name="time-unit" - required - /> - 5분 - - - - handleChangeReservationForm(event, onChangeReservationTimeUnit) - } - name="time-unit" - /> - 10분 - - - - handleChangeReservationForm(event, onChangeReservationTimeUnit) - } - name="time-unit" - /> - 30분 - - - - handleChangeReservationForm(event, onChangeReservationTimeUnit) - } - name="time-unit" - /> - 1시간 - - - - - 예약할 때의 시간 단위를 설정할 수 있습니다. - - - - - - - handleChangeReservationForm(event, onChangeReservationMinimumTimeUnit) - } - required - /> - - handleChangeReservationForm(event, onChangeReservationMaximumTimeUnit) - } - required - /> - - - 예약 가능한 최소 시간과 최대 시간을 설정할 수 있습니다. - - - - - - 예약 가능한 요일 - - - - - handleChangeReservationForm(event, handleChangeEnabledDayOfWeek) - } - /> - - - - - handleChangeReservationForm(event, handleChangeEnabledDayOfWeek) - } - /> - - - - - handleChangeReservationForm(event, handleChangeEnabledDayOfWeek) - } - /> - - - - - handleChangeReservationForm(event, handleChangeEnabledDayOfWeek) - } - /> - - - - - handleChangeReservationForm(event, handleChangeEnabledDayOfWeek) - } - /> - - - - - handleChangeReservationForm(event, handleChangeEnabledDayOfWeek) - } - /> - - - - - handleChangeReservationForm(event, handleChangeEnabledDayOfWeek) - } - /> - - - - - - {isAddingSpace ? ( - - - - - ) : ( - - - - 공간 삭제 - - - - )} - - - ) : ( - 공간을 선택해주세요 - )} - - - - - setPresetFormOpen(false)} - > - 추가할 프리셋의 이름을 입력해주세요 - - - - - - - - - - - ); -}; - -export default ManagerSpaceEdit; diff --git a/frontend/src/pages/ManagerSpaceEditor/ManagerSpaceEditor.styles.ts b/frontend/src/pages/ManagerSpaceEditor/ManagerSpaceEditor.styles.ts new file mode 100644 index 000000000..524dfcbc8 --- /dev/null +++ b/frontend/src/pages/ManagerSpaceEditor/ManagerSpaceEditor.styles.ts @@ -0,0 +1,69 @@ +import styled from 'styled-components'; +import { Z_INDEX } from 'constants/style'; + +interface FormContainerProps { + disabled: boolean; +} + +export const Page = styled.div` + padding: 2rem 0; + display: flex; + flex-direction: column; + gap: 1rem; + height: calc(100vh - 3rem); +`; + +export const EditorMain = styled.div` + flex: 1; + display: flex; + justify-content: space-between; + gap: 1.5rem; + overflow: hidden; +`; + +export const EditorContainer = styled.div` + position: relative; + display: flex; + flex: 2; + height: 100%; + border: 1px solid ${({ theme }) => theme.gray[300]}; + border-radius: 0.25rem; +`; + +export const FormContainer = styled.div` + position: relative; + display: flex; + flex-direction: column; + flex: 1; + height: 100%; + min-width: 22rem; + border: 1px solid ${({ theme }) => theme.gray[300]}; + border-radius: 0.25rem; + + &::before { + ${({ disabled }) => (disabled ? `content: ''` : '')}; + position: absolute; + top: 0; + left: 0; + display: block; + width: 100%; + height: 100%; + background-color: ${({ theme }) => theme.gray[200]}; + opacity: 0.3; + z-index: ${Z_INDEX.MODAL_OVERLAY}; + } +`; + +export const AddButtonWrapper = styled.div` + display: inline-block; + position: absolute; + right: 1.5rem; + bottom: -1.25rem; + z-index: ${Z_INDEX.SPACE_ADD_BUTTON}; +`; + +export const NoSpaceMessage = styled.p` + text-align: center; + font-size: 1.125rem; + margin: 3rem auto; +`; diff --git a/frontend/src/pages/ManagerSpaceEditor/ManagerSpaceEditor.tsx b/frontend/src/pages/ManagerSpaceEditor/ManagerSpaceEditor.tsx new file mode 100644 index 000000000..65f1d64e7 --- /dev/null +++ b/frontend/src/pages/ManagerSpaceEditor/ManagerSpaceEditor.tsx @@ -0,0 +1,187 @@ +import { AxiosError } from 'axios'; +import { useEffect, useMemo, useState } from 'react'; +import { useMutation } from 'react-query'; +import { useParams } from 'react-router-dom'; +import { + deleteManagerSpace, + DeleteManagerSpaceParams, + postManagerSpace, + PostManagerSpaceParams, + putManagerSpace, + PutManagerSpaceParams, +} from 'api/managerSpace'; +import Header from 'components/Header/Header'; +import Layout from 'components/Layout/Layout'; +import { BOARD } from 'constants/editor'; +import MESSAGE from 'constants/message'; +import useBoardStatus from 'hooks/board/useBoardStatus'; +import useManagerMap from 'hooks/query/useManagerMap'; +import useManagerSpaces from 'hooks/query/useManagerSpaces'; +import useListenManagerMainState from 'hooks/useListenManagerMainState'; +import { ManagerSpace, MapDrawing, SpaceArea } from 'types/common'; +import { SpaceEditorMode as Mode } from 'types/editor'; +import { ErrorResponse } from 'types/response'; +import * as Styled from './ManagerSpaceEditor.styles'; +import { drawingModes } from './data'; +import SpaceFormProvider from './providers/SpaceFormProvider'; +import Editor from './units/Editor'; +import EditorHeader from './units/EditorHeader'; +import Form from './units/Form'; +import ShapeSelectToolbar from './units/ShapeSelectToolbar'; +import SpaceAddButton from './units/SpaceAddButton'; +import SpaceSelect from './units/SpaceSelect'; + +interface CreateResponseHeaders { + location: string; +} + +const ManagerSpaceEditor = (): JSX.Element => { + const { mapId } = useParams<{ mapId: string }>(); + useListenManagerMainState({ mapId: Number(mapId) }); + const map = useManagerMap({ mapId: Number(mapId) }); + const mapName = map.data?.data.mapName ?? ''; + const { width, height, mapElements } = useMemo(() => { + try { + return JSON.parse(map.data?.data.mapDrawing ?? '{}') as MapDrawing; + } catch (error) { + alert(MESSAGE.MANAGER_SPACE.GET_UNEXPECTED_ERROR); + + return { width: BOARD.DEFAULT_WIDTH, height: BOARD.DEFAULT_HEIGHT, mapElements: [] }; + } + }, [map.data?.data.mapDrawing]); + + const managerSpaces = useManagerSpaces({ mapId: Number(mapId) }); + const spaces: ManagerSpace[] = useMemo(() => { + try { + return ( + managerSpaces.data?.data.spaces.map((space) => ({ + ...space, + area: JSON.parse(space.area) as SpaceArea, + })) ?? [] + ); + } catch (error) { + alert(MESSAGE.MANAGER_SPACE.GET_UNEXPECTED_ERROR); + + return []; + } + }, [managerSpaces.data?.data.spaces]); + + const [mode, setMode] = useState(Mode.Default); + const [board, setBoard] = useBoardStatus({ width, height }); + const [selectedSpaceId, setSelectedSpaceId] = useState(null); + + const isDrawingMode = drawingModes.includes(mode); + + const createSpace = useMutation(postManagerSpace, { + onSuccess: async (response) => { + const { location } = response.headers as CreateResponseHeaders; + const newSpaceId = Number(location.split('/').pop() ?? ''); + + await managerSpaces.refetch(); + setSelectedSpaceId(newSpaceId); + alert(MESSAGE.MANAGER_SPACE.SPACE_CREATED); + }, + onError: (error: AxiosError) => { + alert(error.response?.data.message ?? MESSAGE.MANAGER_SPACE.ADD_UNEXPECTED_ERROR); + }, + }); + + const updateSpace = useMutation(putManagerSpace, { + onSuccess: () => { + managerSpaces.refetch(); + alert(MESSAGE.MANAGER_SPACE.SPACE_SETTING_UPDATED); + }, + onError: (error: AxiosError) => { + alert(error.response?.data.message ?? MESSAGE.MANAGER_SPACE.EDIT_UNEXPECTED_ERROR); + }, + }); + + const deleteSpace = useMutation(deleteManagerSpace, { + onSuccess: () => { + setSelectedSpaceId(null); + setMode(Mode.Default); + + managerSpaces.refetch(); + alert(MESSAGE.MANAGER_SPACE.SPACE_DELETED); + }, + onError: (error: AxiosError) => { + alert(error.response?.data.message ?? MESSAGE.MANAGER_SPACE.DELETE_UNEXPECTED_ERROR); + }, + }); + + const handleCreateSpace = (data: Omit) => + createSpace.mutate({ mapId: Number(mapId), ...data }); + + const handleUpdateSpace = (data: Omit) => + updateSpace.mutate({ mapId: Number(mapId), ...data }); + + const handleDeleteSpace = (data: Omit) => + deleteSpace.mutate({ mapId: Number(mapId), ...data }); + + const handleAddSpace = () => { + setMode(Mode.Rect); + setSelectedSpaceId(null); + }; + + useEffect(() => { + if (selectedSpaceId === null) return; + + setMode(Mode.Form); + }, [selectedSpaceId]); + + return ( + <> +
+ + + + + + + + {isDrawingMode && } + + + + + + + + + + + + {mode === Mode.Form || isDrawingMode ? ( +
+ ) : ( + 공간을 선택해주세요 + )} + + + + + + + ); +}; + +export default ManagerSpaceEditor; diff --git a/frontend/src/pages/ManagerSpaceEditor/data.ts b/frontend/src/pages/ManagerSpaceEditor/data.ts new file mode 100644 index 000000000..5e488c4a4 --- /dev/null +++ b/frontend/src/pages/ManagerSpaceEditor/data.ts @@ -0,0 +1,62 @@ +import PALETTE from 'constants/palette'; +import { Area } from 'types/common'; +import { SpaceEditorMode } from 'types/editor'; +import { formatDate, formatTimeWithSecond } from 'utils/datetime'; + +export interface SpaceFormValue { + name: string; + color: string; + availableStartTime: string; + availableEndTime: string; + reservationTimeUnit: string | number; + reservationMinimumTimeUnit: string | number; + reservationMaximumTimeUnit: string | number; + reservationEnable: boolean; + enabledWeekdays: { + monday: boolean; + tuesday: boolean; + wednesday: boolean; + thursday: boolean; + friday: boolean; + saturday: boolean; + sunday: boolean; + }; + area: Area | null; +} + +const today = formatDate(new Date()); + +export const initialSpaceFormValue: Omit = { + reservationEnable: true, + name: '', + color: PALETTE.RED[500], + availableStartTime: formatTimeWithSecond(new Date(`${today}T07:00:00`)), + availableEndTime: formatTimeWithSecond(new Date(`${today}T23:00:00`)), + reservationTimeUnit: '10', + reservationMinimumTimeUnit: '10', + reservationMaximumTimeUnit: '1440', +}; + +export const initialEnabledWeekdays: SpaceFormValue['enabledWeekdays'] = { + monday: true, + tuesday: true, + wednesday: true, + thursday: true, + friday: true, + saturday: true, + sunday: true, +}; + +export const colorSelectOptions = [ + PALETTE.RED[500], + PALETTE.ORANGE[500], + PALETTE.YELLOW[500], + PALETTE.GREEN[500], + PALETTE.BLUE[300], + PALETTE.BLUE[900], + PALETTE.PURPLE[500], +]; + +export const timeUnits = ['5', '10', '30', '60']; + +export const drawingModes = [SpaceEditorMode.Rect, SpaceEditorMode.Polygon]; diff --git a/frontend/src/pages/ManagerSpaceEditor/hooks/useDrawingRect.ts b/frontend/src/pages/ManagerSpaceEditor/hooks/useDrawingRect.ts new file mode 100644 index 000000000..0ab517f99 --- /dev/null +++ b/frontend/src/pages/ManagerSpaceEditor/hooks/useDrawingRect.ts @@ -0,0 +1,57 @@ +import { useState } from 'react'; +import { EDITOR } from 'constants/editor'; +import { Area, Coordinate } from 'types/common'; +import { DrawingAreaShape } from 'types/editor'; + +const useDrawingRect = ( + coordinate: Coordinate +): { + rect: Area | null; + startDrawingRect: () => void; + updateRect: () => void; + endDrawingRect: () => void; +} => { + const [drawingStartCoordinate, setDrawingStartCoordinate] = useState(null); + const [rect, setRect] = useState(null); + + const getRect = (): Area | null => { + if (!drawingStartCoordinate) return null; + + const [startX, endX] = + drawingStartCoordinate.x > coordinate.x + ? [coordinate.x, drawingStartCoordinate.x] + : [drawingStartCoordinate.x, coordinate.x]; + const [startY, endY] = + drawingStartCoordinate.y > coordinate.y + ? [coordinate.y, drawingStartCoordinate.y] + : [drawingStartCoordinate.y, coordinate.y]; + + const width = Math.abs(startX - endX) + EDITOR.GRID_SIZE; + const height = Math.abs(startY - endY) + EDITOR.GRID_SIZE; + + return { + shape: DrawingAreaShape.Rect, + x: startX, + y: startY, + width, + height, + }; + }; + + const startDrawingRect = () => { + setDrawingStartCoordinate(coordinate); + }; + + const updateRect = () => { + setRect(getRect()); + }; + + const endDrawingRect = () => { + setDrawingStartCoordinate(null); + setRect(null); + }; + + return { rect, startDrawingRect, updateRect, endDrawingRect }; +}; + +export default useDrawingRect; diff --git a/frontend/src/pages/ManagerSpaceEditor/providers/SpaceFormProvider.tsx b/frontend/src/pages/ManagerSpaceEditor/providers/SpaceFormProvider.tsx new file mode 100644 index 000000000..82f1123f3 --- /dev/null +++ b/frontend/src/pages/ManagerSpaceEditor/providers/SpaceFormProvider.tsx @@ -0,0 +1,147 @@ +import React, { createContext, Dispatch, ReactNode, SetStateAction, useState } from 'react'; +import useInputs from 'hooks/useInputs'; +import { Area, ManagerSpace, ManagerSpaceAPI } from 'types/common'; +import { WithOptional } from 'types/util'; +import { formatDate, formatTimeWithSecond } from 'utils/datetime'; +import { initialEnabledWeekdays, initialSpaceFormValue, SpaceFormValue } from '../data'; + +interface Props { + children: ReactNode; +} + +export interface SpaceProviderValue { + values: SpaceFormValue; + onChange: (event: React.ChangeEvent) => void; + resetForm: () => void; + updateArea: (nextArea: Area) => void; + updateWithSpace: (space: ManagerSpace) => void; + setValues: (nextValue: SpaceFormValue) => void; + getRequestValues: () => { + space: WithOptional; + }; + selectedPresetId: number | null; + setSelectedPresetId: Dispatch>; +} + +export const SpaceFormContext = createContext(null); +const weekdays = Object.keys(initialEnabledWeekdays); + +const SpaceFormProvider = ({ children }: Props): JSX.Element => { + const [spaceFormValue, onChangeSpaceFormValues, setSpaceFormValues] = + useInputs(initialSpaceFormValue); + const [enabledWeekdays, onChangeEnabledWeekdays, setEnabledWeekdays] = + useInputs(initialEnabledWeekdays); + const [area, setArea] = useState(null); + const [selectedPresetId, setSelectedPresetId] = useState(null); + + const values = { ...spaceFormValue, enabledWeekdays, area }; + + const setValues = (values: SpaceFormValue) => { + setEnabledWeekdays({ ...values.enabledWeekdays }); + setArea(values.area === null ? null : { ...values.area }); + + const nextValues = { ...values }; + + delete (nextValues as WithOptional).enabledWeekdays; + delete (nextValues as WithOptional).area; + + setSpaceFormValues(nextValues); + }; + + const updateWithSpace = (space: ManagerSpace) => { + const { name, color, area, settings } = space; + + const enableWeekdays = settings.enabledDayOfWeek?.split(',') ?? []; + const nextEnableWeekdays: { [key: string]: boolean } = {}; + Object.keys(values.enabledWeekdays).forEach( + (weekday) => (nextEnableWeekdays[weekday] = enableWeekdays.includes(weekday)) + ); + + setSelectedPresetId(null); + setValues({ + name, + color, + ...settings, + enabledWeekdays: nextEnableWeekdays as SpaceFormValue['enabledWeekdays'], + area, + }); + }; + + const updateArea = (nextArea: Area) => { + setArea(nextArea); + setSpaceFormValues(initialSpaceFormValue); + setEnabledWeekdays(initialEnabledWeekdays); + }; + + const getRequestValues = () => { + const todayDate = formatDate(new Date()); + + const availableStartTime = formatTimeWithSecond( + new Date(`${todayDate}T${values.availableStartTime}`) + ); + const availableEndTime = formatTimeWithSecond( + new Date(`${todayDate}T${values.availableEndTime}`) + ); + + const enabledDayOfWeek = Object.keys(values.enabledWeekdays) + .filter( + (weekday) => values.enabledWeekdays[weekday as keyof SpaceFormValue['enabledWeekdays']] + ) + .join(); + + return { + space: { + name: values.name, + color: values.color, + description: values.name, + area: JSON.stringify(values.area), + settings: { + availableStartTime, + availableEndTime, + reservationTimeUnit: Number(values.reservationTimeUnit), + reservationMinimumTimeUnit: Number(values.reservationMinimumTimeUnit), + reservationMaximumTimeUnit: Number(values.reservationMaximumTimeUnit), + reservationEnable: values.reservationEnable, + enabledDayOfWeek, + }, + }, + }; + }; + + const onChange = (event: React.ChangeEvent) => { + if (selectedPresetId !== null) setSelectedPresetId(null); + + if (weekdays.includes(event.target.name)) { + onChangeEnabledWeekdays(event); + + return; + } + + onChangeSpaceFormValues(event); + }; + + const resetForm = () => { + setSelectedPresetId(null); + setValues({ ...initialSpaceFormValue, enabledWeekdays: initialEnabledWeekdays, area: null }); + }; + + return ( + + {children} + + ); +}; + +export default SpaceFormProvider; diff --git a/frontend/src/pages/ManagerSpaceEditor/units/BoardCursorRect.tsx b/frontend/src/pages/ManagerSpaceEditor/units/BoardCursorRect.tsx new file mode 100644 index 000000000..816fbf25c --- /dev/null +++ b/frontend/src/pages/ManagerSpaceEditor/units/BoardCursorRect.tsx @@ -0,0 +1,18 @@ +import PALETTE from 'constants/palette'; +import { Color, Coordinate } from 'types/common'; + +interface Props { + coordinate: Coordinate; + size: number; + color?: Color; +} + +const BoardCursorRect = ({ + coordinate, + size, + color = PALETTE.OPACITY_BLACK[100], +}: Props): JSX.Element => { + return ; +}; + +export default BoardCursorRect; diff --git a/frontend/src/pages/ManagerSpaceEditor/units/BoardMapElement.tsx b/frontend/src/pages/ManagerSpaceEditor/units/BoardMapElement.tsx new file mode 100644 index 000000000..2f57c43ef --- /dev/null +++ b/frontend/src/pages/ManagerSpaceEditor/units/BoardMapElement.tsx @@ -0,0 +1,36 @@ +import { EDITOR } from 'constants/editor'; +import { MapElement as MapElementData } from 'types/common'; +import { MapElementType } from 'types/editor'; + +interface Props { + mapElement: MapElementData; +} + +const BoardMapElement = ({ mapElement }: Props): JSX.Element => { + if (mapElement.type === MapElementType.Polyline) { + return ( + + ); + } + + return ( + + ); +}; + +export default BoardMapElement; diff --git a/frontend/src/pages/ManagerSpaceEditor/units/BoardSpace.styles.ts b/frontend/src/pages/ManagerSpaceEditor/units/BoardSpace.styles.ts new file mode 100644 index 000000000..30d8d308a --- /dev/null +++ b/frontend/src/pages/ManagerSpaceEditor/units/BoardSpace.styles.ts @@ -0,0 +1,24 @@ +import styled from 'styled-components'; + +interface SpaceRectProps { + disabled: boolean; + selected: boolean; +} + +export const SpaceRect = styled.rect` + opacity: ${({ selected }) => (selected ? '0.5' : '0.3')}; + cursor: ${({ disabled }) => (disabled ? 'default' : 'pointer')}; + + &:hover { + ${({ disabled }) => (disabled ? '' : 'opacity: 0.2;')} + } +`; + +export const SpaceText = styled.text` + dominant-baseline: middle; + text-anchor: middle; + fill: ${({ theme }) => theme.black[700]}; + font-size: 1rem; + pointer-events: none; + user-select: none; +`; diff --git a/frontend/src/pages/ManagerSpaceEditor/units/BoardSpace.tsx b/frontend/src/pages/ManagerSpaceEditor/units/BoardSpace.tsx new file mode 100644 index 000000000..8fa334f97 --- /dev/null +++ b/frontend/src/pages/ManagerSpaceEditor/units/BoardSpace.tsx @@ -0,0 +1,34 @@ +import { ManagerSpace } from 'types/common'; +import { WithOptional } from 'types/util'; +import * as Styled from './BoardSpace.styles'; + +interface Props { + space: WithOptional; + drawing: boolean; + selected: boolean; + onClick?: () => void; +} + +const BoardSpace = ({ space, drawing, selected, onClick }: Props): JSX.Element => { + const { color, area, name } = space; + + return ( + + + + {name} + + + ); +}; + +export default BoardSpace; diff --git a/frontend/src/pages/ManagerSpaceEditor/units/Editor.tsx b/frontend/src/pages/ManagerSpaceEditor/units/Editor.tsx new file mode 100644 index 000000000..cbd43c1be --- /dev/null +++ b/frontend/src/pages/ManagerSpaceEditor/units/Editor.tsx @@ -0,0 +1,163 @@ +import { + Dispatch, + MouseEventHandler, + SetStateAction, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; +import Board from 'components/Board/Board'; +import { EDITOR, KEY } from 'constants/editor'; +import PALETTE from 'constants/palette'; +import useBindKeyPress from 'hooks/board/useBindKeyPress'; +import useBoardCoordinate from 'hooks/board/useBoardCoordinate'; +import useBoardMove from 'hooks/board/useBoardMove'; +import useBoardZoom from 'hooks/board/useBoardZoom'; +import useFormContext from 'hooks/useFormContext'; +import { Area, EditorBoard, ManagerSpace, MapElement } from 'types/common'; +import { SpaceEditorMode as Mode } from 'types/editor'; +import { drawingModes } from '../data'; +import useDrawingRect from '../hooks/useDrawingRect'; +import { SpaceFormContext } from '../providers/SpaceFormProvider'; +import BoardCursorRect from './BoardCursorRect'; +import BoardMapElement from './BoardMapElement'; +import BoardSpace from './BoardSpace'; + +interface Props { + modeState: [Mode, Dispatch>]; + boardState: [EditorBoard, Dispatch>]; + selectedSpaceIdState: [number | null, Dispatch>]; + mapElements: MapElement[]; + spaces: ManagerSpace[]; +} + +const Editor = ({ + modeState, + boardState, + selectedSpaceIdState, + mapElements, + spaces, +}: Props): JSX.Element => { + const [board] = boardState; + const [mode, setMode] = modeState; + const [selectedSpaceId, setSelectedSpaceId] = selectedSpaceIdState; + + const { pressedKey } = useBindKeyPress(); + const [movable, setMovable] = useState(pressedKey === KEY.SPACE); + + const { values, updateWithSpace, updateArea } = useFormContext(SpaceFormContext); + const { stickyRectCoordinate, onMouseMove: updateCoordinate } = useBoardCoordinate(board); + + const { onWheel } = useBoardZoom(boardState); + const { isMoving, onDragStart, onDrag, onDragEnd, onMouseOut } = useBoardMove( + boardState, + movable + ); + + const { rect, startDrawingRect, updateRect, endDrawingRect } = + useDrawingRect(stickyRectCoordinate); + const [isDrawing, setIsDrawing] = useState(false); + + const isDrawingMode = useMemo(() => drawingModes.includes(mode) && !movable, [mode, movable]); + + const unSelectedSpaces = useMemo(() => { + if (selectedSpaceId === null) return spaces; + + return spaces.filter(({ id }) => id !== selectedSpaceId); + }, [spaces, selectedSpaceId]); + + const handleClickSpace = useCallback( + (spaceId: number) => { + if (isDrawingMode) return; + + const selectedSpace = spaces.find((space) => space.id === spaceId); + + updateWithSpace(selectedSpace as ManagerSpace); + setSelectedSpaceId(spaceId); + }, + [isDrawingMode, spaces, setSelectedSpaceId, updateWithSpace] + ); + + const handleDrawingStart = useCallback(() => { + if (!isDrawingMode) return; + + setIsDrawing(true); + + if (mode === Mode.Rect) startDrawingRect(); + }, [isDrawingMode, setIsDrawing, mode, startDrawingRect]); + + const handleMouseMove: MouseEventHandler = useCallback( + (event) => { + updateCoordinate(event); + + if (!isDrawingMode || !isDrawing) return; + + if (mode === Mode.Rect) { + updateRect(); + updateArea(rect as Area); + } + }, + [isDrawing, isDrawingMode, mode, rect, updateArea, updateRect, updateCoordinate] + ); + + const handleDrawingEnd = useCallback(() => { + if (!isDrawingMode || !isDrawing) return; + + setMode(Mode.Form); + endDrawingRect(); + setIsDrawing(false); + }, [isDrawing, isDrawingMode, setMode, endDrawingRect]); + + useEffect(() => { + setMovable(pressedKey === KEY.SPACE); + }, [movable, pressedKey]); + + return ( + + {values.area && ( + + )} + + {isDrawingMode && ( + + )} + + {unSelectedSpaces?.map((space, index) => ( + handleClickSpace(space.id)} + /> + ))} + + {mapElements?.map((element, index) => ( + + ))} + + ); +}; + +export default Editor; diff --git a/frontend/src/pages/ManagerSpaceEditor/units/EditorHeader.styles.ts b/frontend/src/pages/ManagerSpaceEditor/units/EditorHeader.styles.ts new file mode 100644 index 000000000..c2743ff39 --- /dev/null +++ b/frontend/src/pages/ManagerSpaceEditor/units/EditorHeader.styles.ts @@ -0,0 +1,13 @@ +import styled from 'styled-components'; + +export const Container = styled.div` + display: flex; + justify-content: space-between; + align-items: center; +`; + +export const MapName = styled.h2` + font-size: 1.5rem; +`; + +export const ButtonContainer = styled.div``; diff --git a/frontend/src/pages/ManagerSpaceEditor/units/EditorHeader.tsx b/frontend/src/pages/ManagerSpaceEditor/units/EditorHeader.tsx new file mode 100644 index 000000000..0f87e9ff2 --- /dev/null +++ b/frontend/src/pages/ManagerSpaceEditor/units/EditorHeader.tsx @@ -0,0 +1,24 @@ +import { Link } from 'react-router-dom'; +import Button from 'components/Button/Button'; +import PATH from 'constants/path'; +import * as Styled from './EditorHeader.styles'; + +interface Props { + mapName: string; +} + +// TODO: Link태그 내 Button 태그 제거 +const EditorHeader = ({ mapName }: Props): JSX.Element => { + return ( + + {mapName} + + + + + + + ); +}; + +export default EditorHeader; diff --git a/frontend/src/pages/ManagerSpaceEditor/units/Form.styles.ts b/frontend/src/pages/ManagerSpaceEditor/units/Form.styles.ts new file mode 100644 index 000000000..1260b114d --- /dev/null +++ b/frontend/src/pages/ManagerSpaceEditor/units/Form.styles.ts @@ -0,0 +1,119 @@ +import styled from 'styled-components'; +import Button from 'components/Button/Button'; + +interface FormContainerProps { + disabled: boolean; +} + +export const Form = styled.form` + padding: 2rem 1.5rem; + overflow-y: ${({ disabled }) => (disabled ? 'hidden' : 'auto')}; + flex: 1; + display: flex; + flex-direction: column; + gap: 2rem; +`; + +export const Section = styled.section``; + +export const Title = styled.h3` + font-size: 1.25rem; +`; + +export const TitleContainer = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +`; + +export const ContentsContainer = styled.div` + display: flex; + flex-direction: column; + gap: 1.375rem; +`; + +export const Row = styled.div``; + +export const ColorSelect = styled.div` + display: flex; + justify-content: space-between; + align-items: center; +`; + +export const ColorInputLabel = styled.label` + display: inline-block; + padding: 0.5rem; + border: 1px solid ${({ theme }) => theme.gray[500]}; + border-radius: 0.125rem; + cursor: pointer; +`; + +export const ColorInput = styled.input` + width: 0; + height: 0; + opacity: 0; + padding: 0; +`; + +export const ColorDotButton = styled.button` + background: none; + border: none; + padding: 0; + cursor: pointer; +`; + +export const InputWrapper = styled.div` + display: flex; + gap: 1rem; + + label { + flex: 1; + } +`; + +export const InputMessage = styled.p` + font-size: 0.75rem; + margin: 0.25rem 0.5rem; + color: ${({ theme }) => theme.gray[400]}; +`; + +export const Label = styled.div` + font-size: 0.75rem; + color: ${({ theme }) => theme.gray[500]}; +`; + +export const Fieldset = styled.div` + position: relative; + border: 1px solid ${({ theme }) => theme.gray[500]}; + border-radius: 0.125rem; + padding: 1rem 0.75rem; + margin-top: 0.375rem; + + ${Label} { + position: absolute; + top: -0.375rem; + left: 0.75rem; + padding: 0 0.25rem; + background-color: ${({ theme }) => theme.white}; + } +`; + +export const FormSubmitContainer = styled.div` + display: flex; + justify-content: flex-end; +`; + +export const DeleteButton = styled(Button)` + color: ${({ theme }) => theme.red[900]}; + display: inline-flex; + align-items: center; + gap: 0.25rem; + + svg { + width: 1rem; + height: 1rem; + vertical-align: middle; + fill: ${({ theme }) => theme.red[900]}; + } +`; diff --git a/frontend/src/pages/ManagerSpaceEditor/units/Form.tsx b/frontend/src/pages/ManagerSpaceEditor/units/Form.tsx new file mode 100644 index 000000000..f70d5076b --- /dev/null +++ b/frontend/src/pages/ManagerSpaceEditor/units/Form.tsx @@ -0,0 +1,279 @@ +import { Dispatch, FormEventHandler, SetStateAction, useEffect, useRef } from 'react'; +import { + DeleteManagerSpaceParams, + PostManagerSpaceParams, + PutManagerSpaceParams, +} from 'api/managerSpace'; +import { ReactComponent as DeleteIcon } from 'assets/svg/delete.svg'; +import { ReactComponent as PaletteIcon } from 'assets/svg/palette.svg'; +import Button from 'components/Button/Button'; +import ColorDot from 'components/ColorDot/ColorDot'; +import Input from 'components/Input/Input'; +import Toggle from 'components/Toggle/Toggle'; +import MESSAGE from 'constants/message'; +import useFormContext from 'hooks/useFormContext'; +import { Area, Color, ManagerSpace, MapElement } from 'types/common'; +import { SpaceEditorMode as Mode } from 'types/editor'; +import { generateSvg, MapSvgData } from 'utils/generateSvg'; +import { colorSelectOptions, timeUnits } from '../data'; +import { SpaceFormContext } from '../providers/SpaceFormProvider'; +import * as Styled from './Form.styles'; +import FormTimeUnitSelect from './FormTimeUnitSelect'; +import FormWeekdaySelect from './FormWeekdaySelect'; +import Preset from './Preset'; + +interface Props { + modeState: [Mode, Dispatch>]; + mapData: { width: number; height: number; mapElements: MapElement[] }; + spaces: ManagerSpace[]; + selectedSpaceId: number | null; + disabled: boolean; + onCreateSpace: (data: Omit) => void; + onUpdateSpace: (data: Omit) => void; + onDeleteSpace: (data: Omit) => void; +} + +const Form = ({ + modeState, + mapData, + spaces, + selectedSpaceId, + disabled, + onCreateSpace, + onUpdateSpace, + onDeleteSpace, +}: Props): JSX.Element => { + const nameInputRef = useRef(null); + + const [mode, setMode] = modeState; + + const { values, onChange, resetForm, setValues, getRequestValues } = + useFormContext(SpaceFormContext); + + const setColor = (color: Color) => { + setValues({ ...values, color }); + }; + + const getSpacesForSvg = (): MapSvgData['spaces'] => { + if (selectedSpaceId === null && values.area) { + return [...spaces, { area: values.area, color: values.color }]; + } + + return spaces.map((space) => + space.id === selectedSpaceId ? { area: values.area as Area, color: values.color } : space + ); + }; + + const handleSubmit: FormEventHandler = (event) => { + event.preventDefault(); + + const mapImageSvg = generateSvg({ ...mapData, spaces: getSpacesForSvg() }); + const valuesForRequest = getRequestValues(); + + if (selectedSpaceId === null) { + onCreateSpace({ + space: { + mapImageSvg, + ...valuesForRequest.space, + settingsRequest: { ...valuesForRequest.space.settings }, + }, + }); + + return; + } + + onUpdateSpace({ + spaceId: selectedSpaceId, + space: { + mapImageSvg, + ...valuesForRequest.space, + settingsRequest: { ...valuesForRequest.space.settings }, + }, + }); + }; + + const handleDelete = () => { + if (selectedSpaceId === null) return; + if (!window.confirm(MESSAGE.MANAGER_SPACE.DELETE_SPACE_CONFIRM)) return; + + const filteredSpaces = spaces.filter(({ id }) => id !== selectedSpaceId); + const mapImageSvg = generateSvg({ ...mapData, spaces: filteredSpaces }); + + onDeleteSpace({ + spaceId: selectedSpaceId, + mapImageSvg, + }); + + resetForm(); + }; + + const handleCancel = () => { + resetForm(); + + setMode(Mode.Default); + }; + + useEffect(() => { + if (mode !== Mode.Form) return; + + nameInputRef.current?.focus(); + }, [mode, selectedSpaceId]); + + return ( + + + + 공간 설정 + + + + + + } + label="공간 이름" + value={values.name} + name="name" + onChange={onChange} + ref={nameInputRef} + required + /> + + + + + + + + + {colorSelectOptions.map((color) => ( + setColor(color)}> + + + ))} + + + + + + + + 예약 조건 + + + + + + + + + + + + + 예약이 열릴 시간과 닫힐 시간을 설정해주세요. + + + + + 예약 시간 단위 + + + 예약 시간의 단위를 설정해주세요. + + + + + + + + + 예약 가능한 최소 시간과 최대 시간을 설정해주세요. + + + + + + 예약 가능한 요일 + + + + + + {selectedSpaceId ? ( + + + + 공간 삭제 + + + + ) : ( + + + + + )} + + + + + ); +}; + +export default Form; diff --git a/frontend/src/pages/ManagerSpaceEditor/units/FormTimeUnitSelect.styles.ts b/frontend/src/pages/ManagerSpaceEditor/units/FormTimeUnitSelect.styles.ts new file mode 100644 index 000000000..7feddffac --- /dev/null +++ b/frontend/src/pages/ManagerSpaceEditor/units/FormTimeUnitSelect.styles.ts @@ -0,0 +1,12 @@ +import styled from 'styled-components'; + +export const Container = styled.div` + display: flex; + justify-content: space-between; +`; + +export const Label = styled.label``; + +export const Input = styled.input``; + +export const LabelText = styled.span``; diff --git a/frontend/src/pages/ManagerSpaceEditor/units/FormTimeUnitSelect.tsx b/frontend/src/pages/ManagerSpaceEditor/units/FormTimeUnitSelect.tsx new file mode 100644 index 000000000..8e6fd8d4a --- /dev/null +++ b/frontend/src/pages/ManagerSpaceEditor/units/FormTimeUnitSelect.tsx @@ -0,0 +1,31 @@ +import { ChangeEventHandler } from 'react'; +import * as Styled from './FormTimeUnitSelect.styles'; + +interface Props { + timeUnits: string[]; + name: string; + selectedValue: string; + onChange: ChangeEventHandler; +} + +const FormTimeUnitSelect = ({ timeUnits, name, onChange, selectedValue }: Props): JSX.Element => { + return ( + + {timeUnits.map((timeUnit) => ( + + + {timeUnit}분 + + ))} + + ); +}; + +export default FormTimeUnitSelect; diff --git a/frontend/src/pages/ManagerSpaceEditor/units/FormWeekdaySelect.styles.ts b/frontend/src/pages/ManagerSpaceEditor/units/FormWeekdaySelect.styles.ts new file mode 100644 index 000000000..363c9f2cc --- /dev/null +++ b/frontend/src/pages/ManagerSpaceEditor/units/FormWeekdaySelect.styles.ts @@ -0,0 +1,32 @@ +import styled, { css } from 'styled-components'; + +interface WeekdayProps { + value?: 'Saturday' | 'Sunday'; +} + +export const Container = styled.div` + display: flex; + justify-content: space-between; + align-items: center; +`; + +export const Label = styled.label` + display: inline-flex; + flex-direction: column; + align-items: center; + cursor: pointer; +`; + +const weekdayColorCSS = { + default: css``, + Saturday: css` + color: ${({ theme }) => theme.blue[900]}; + `, + Sunday: css` + color: ${({ theme }) => theme.red[500]}; + `, +}; + +export const DisplayName = styled.span` + ${({ value }) => weekdayColorCSS[value ?? 'default']}; +`; diff --git a/frontend/src/pages/ManagerSpaceEditor/units/FormWeekdaySelect.tsx b/frontend/src/pages/ManagerSpaceEditor/units/FormWeekdaySelect.tsx new file mode 100644 index 000000000..4022346c6 --- /dev/null +++ b/frontend/src/pages/ManagerSpaceEditor/units/FormWeekdaySelect.tsx @@ -0,0 +1,73 @@ +import { ChangeEventHandler } from 'react'; +import * as Styled from './FormWeekdaySelect.styles'; + +interface Props { + onChange: ChangeEventHandler; + enabledWeekdays: { [key: string]: boolean }; +} + +interface Weekday { + displayName: string; + inputName: T; +} + +const weekday: { [key in keyof Props['enabledWeekdays']]: Weekday } = { + monday: { + displayName: '월', + inputName: 'monday', + }, + tuesday: { + displayName: '화', + inputName: 'tuesday', + }, + wednesday: { + displayName: '수', + inputName: 'wednesday', + }, + thursday: { + displayName: '목', + inputName: 'thursday', + }, + friday: { + displayName: '금', + inputName: 'friday', + }, + saturday: { + displayName: '토', + inputName: 'saturday', + }, + sunday: { + displayName: '일', + inputName: 'sunday', + }, +}; + +const displayOrder = [ + weekday.monday, + weekday.tuesday, + weekday.wednesday, + weekday.thursday, + weekday.friday, + weekday.saturday, + weekday.sunday, +]; + +const FormWeekdaySelect = ({ onChange, enabledWeekdays }: Props): JSX.Element => { + return ( + + {displayOrder.map(({ displayName, inputName }) => ( + + {displayName} + + + ))} + + ); +}; + +export default FormWeekdaySelect; diff --git a/frontend/src/pages/ManagerSpaceEditor/units/Preset.styles.ts b/frontend/src/pages/ManagerSpaceEditor/units/Preset.styles.ts new file mode 100644 index 000000000..3cc138424 --- /dev/null +++ b/frontend/src/pages/ManagerSpaceEditor/units/Preset.styles.ts @@ -0,0 +1,27 @@ +import styled from 'styled-components'; + +export const PresetSelect = styled.div` + display: flex; + gap: 0.5rem; +`; + +export const PresetSelectWrapper = styled.div` + flex: 1; +`; + +export const PresetOption = styled.div` + display: flex; + justify-content: space-between; + align-items: center; +`; + +export const PresetName = styled.span` + display: block; + flex: 1; +`; + +export const PresetNameFormControl = styled.div` + display: flex; + justify-content: flex-end; + margin-top: 1rem; +`; diff --git a/frontend/src/pages/ManagerSpaceEditor/units/Preset.tsx b/frontend/src/pages/ManagerSpaceEditor/units/Preset.tsx new file mode 100644 index 000000000..2194878c3 --- /dev/null +++ b/frontend/src/pages/ManagerSpaceEditor/units/Preset.tsx @@ -0,0 +1,158 @@ +import { AxiosError } from 'axios'; +import { FormEventHandler, useMemo, useState } from 'react'; +import { useMutation } from 'react-query'; +import { deletePreset, postPreset } from 'api/presets'; +import { ReactComponent as DeleteIcon } from 'assets/svg/delete.svg'; +import Button from 'components/Button/Button'; +import IconButton from 'components/IconButton/IconButton'; +import Select from 'components/Select/Select'; +import MESSAGE from 'constants/message'; +import THROW_ERROR from 'constants/throwError'; +import usePresets from 'hooks/query/usePreset'; +import useFormContext from 'hooks/useFormContext'; +import useInput from 'hooks/useInput'; +import { Preset as PresetType } from 'types/common'; +import { ErrorResponse } from 'types/response'; +import { SpaceFormValue } from '../data'; +import { SpaceFormContext } from '../providers/SpaceFormProvider'; +import * as Styled from './Preset.styles'; +import PresetNameModal from './PresetNameModal'; + +interface CreateResponseHeaders { + location: string; +} + +const Preset = (): JSX.Element => { + const { values, setValues, selectedPresetId, setSelectedPresetId, getRequestValues } = + useFormContext(SpaceFormContext); + const [isModalOpen, setIsModalOpen] = useState(false); + const [presetName, onChangePresetName] = useInput(''); + + const getPresets = usePresets(); + const presets = useMemo( + () => getPresets.data?.data?.presets ?? [], + [getPresets.data?.data?.presets] + ); + + const createPreset = useMutation(postPreset, { + onSuccess: (response) => { + const { location } = response.headers as CreateResponseHeaders; + const newPresetId = Number(location.split('/').pop() ?? ''); + + setSelectedPresetId(newPresetId); + setIsModalOpen(false); + getPresets.refetch(); + }, + onError: (error: AxiosError) => { + alert(error.response?.data.message ?? MESSAGE.MANAGER_SPACE.ADD_PRESET_UNEXPECTED_ERROR); + }, + }); + + const removePreset = useMutation(deletePreset, { + onError: (error: AxiosError) => { + alert(error.response?.data.message ?? MESSAGE.MANAGER_SPACE.DELETE_PRESET_UNEXPECTED_ERROR); + }, + }); + + const handleSelectPreset = (id: number | null) => { + if (id === null) return; + + const selectedPreset = presets.find((preset) => preset.id === id) ?? null; + + if (selectedPreset === null) throw new Error(THROW_ERROR.NOT_EXIST_PRESET); + + const { + availableStartTime, + availableEndTime, + reservationTimeUnit, + reservationMinimumTimeUnit, + reservationMaximumTimeUnit, + } = selectedPreset; + + const enabledDayOfWeek = selectedPreset.enabledDayOfWeek?.split(',') ?? []; + const enabledWeekdays: { [key: string]: boolean } = {}; + Object.keys(values.enabledWeekdays).forEach( + (weekday) => (enabledWeekdays[weekday] = enabledDayOfWeek?.includes(weekday)) + ); + + setValues({ + ...values, + availableStartTime, + availableEndTime, + reservationTimeUnit, + reservationMinimumTimeUnit, + reservationMaximumTimeUnit, + enabledWeekdays: enabledWeekdays as SpaceFormValue['enabledWeekdays'], + }); + + setSelectedPresetId(id); + }; + + const handleAddPreset = () => { + setIsModalOpen(true); + }; + + const handleSubmitPreset: FormEventHandler = (event) => { + event.preventDefault(); + event.stopPropagation(); + + const requestValues = getRequestValues(); + + createPreset.mutate({ name: presetName, settingsRequest: requestValues.space.settings }); + }; + + const handleDeletePreset = (event: React.MouseEvent, id: PresetType['id']) => { + event.stopPropagation(); + + if (!window.confirm(MESSAGE.MANAGER_SPACE.DELETE_PRESET_CONFIRM)) return; + + removePreset.mutate({ id }); + }; + + const presetOptions = presets.map(({ id, name }) => ({ + value: `${id}`, + title: name, + children: ( + + {name} + handleDeletePreset(event, Number(id))} + > + + + + ), + })); + + return ( + <> + + + + + + + + + + ); +}; + +export default PresetNameModal; diff --git a/frontend/src/pages/ManagerSpaceEditor/units/ShapeSelectToolbar.styles.ts b/frontend/src/pages/ManagerSpaceEditor/units/ShapeSelectToolbar.styles.ts new file mode 100644 index 000000000..8a4fc4a33 --- /dev/null +++ b/frontend/src/pages/ManagerSpaceEditor/units/ShapeSelectToolbar.styles.ts @@ -0,0 +1,33 @@ +import styled, { css } from 'styled-components'; +import IconButton from 'components/IconButton/IconButton'; + +interface ToolbarButtonProps { + selected?: boolean; +} + +const primaryIconCSS = css` + svg { + fill: ${({ theme }) => theme.primary[400]}; + } +`; + +export const Container = styled.div` + position: absolute; + top: 0.5rem; + left: 50%; + transform: translateX(-50%); + margin: 0 auto; + padding: 0.5rem 1rem; + background-color: ${({ theme }) => theme.gray[100]}; + border-right: 1px solid ${({ theme }) => theme.gray[300]}; + display: flex; + gap: 1rem; +`; + +export const ToolbarButton = styled(IconButton)` + background-color: ${({ theme, selected }) => (selected ? theme.gray[100] : 'none')}; + border: 1px solid ${({ theme, selected }) => (selected ? theme.gray[400] : 'transparent')}; + border-radius: 0; + box-sizing: content-box; + ${({ selected }) => selected && primaryIconCSS} +`; diff --git a/frontend/src/pages/ManagerSpaceEditor/units/ShapeSelectToolbar.tsx b/frontend/src/pages/ManagerSpaceEditor/units/ShapeSelectToolbar.tsx new file mode 100644 index 000000000..082386a82 --- /dev/null +++ b/frontend/src/pages/ManagerSpaceEditor/units/ShapeSelectToolbar.tsx @@ -0,0 +1,37 @@ +import { ReactComponent as CloseIcon } from 'assets/svg/close.svg'; +import { ReactComponent as RectIcon } from 'assets/svg/rect.svg'; +import useFormContext from 'hooks/useFormContext'; +import { SpaceEditorMode as Mode } from 'types/editor'; +import { SpaceFormContext } from '../providers/SpaceFormProvider'; +import * as Styled from './ShapeSelectToolbar.styles'; + +interface Props { + mode: Mode; + setMode: (nextMode: Mode) => void; +} + +const ShapeSelectToolbar = ({ mode, setMode }: Props): JSX.Element => { + const { resetForm } = useFormContext(SpaceFormContext); + + const handleCancel = () => { + resetForm(); + setMode(Mode.Default); + }; + + return ( + + + + + setMode(Mode.Rect)} + > + + + + ); +}; + +export default ShapeSelectToolbar; diff --git a/frontend/src/pages/ManagerSpaceEditor/units/SpaceAddButton.tsx b/frontend/src/pages/ManagerSpaceEditor/units/SpaceAddButton.tsx new file mode 100644 index 000000000..ef019bc79 --- /dev/null +++ b/frontend/src/pages/ManagerSpaceEditor/units/SpaceAddButton.tsx @@ -0,0 +1,25 @@ +import { ReactComponent as PlusSmallIcon } from 'assets/svg/plus-small.svg'; +import Button from 'components/Button/Button'; +import useFormContext from 'hooks/useFormContext'; +import { SpaceFormContext } from '../providers/SpaceFormProvider'; + +interface Props { + onClick: () => void; +} + +const SpaceAddButton = ({ onClick }: Props): JSX.Element => { + const { resetForm } = useFormContext(SpaceFormContext); + + const handleAddSpace = () => { + onClick(); + resetForm(); + }; + + return ( + + ); +}; + +export default SpaceAddButton; diff --git a/frontend/src/pages/ManagerSpaceEditor/units/SpaceSelect.styles.ts b/frontend/src/pages/ManagerSpaceEditor/units/SpaceSelect.styles.ts new file mode 100644 index 000000000..ac27d0b18 --- /dev/null +++ b/frontend/src/pages/ManagerSpaceEditor/units/SpaceSelect.styles.ts @@ -0,0 +1,22 @@ +import styled from 'styled-components'; + +export const SpaceSelect = styled.div` + border-bottom: 1px solid ${({ theme }) => theme.gray[300]}; + padding: 1.5rem; + position: relative; +`; + +export const Title = styled.h3` + font-size: 1.25rem; + margin-bottom: 1.5rem; +`; + +export const SpaceSelectWrapper = styled.div` + margin-bottom: 0.5rem; +`; + +export const SpaceOption = styled.div` + display: flex; + align-items: center; + gap: 0.75rem; +`; diff --git a/frontend/src/pages/ManagerSpaceEditor/units/SpaceSelect.tsx b/frontend/src/pages/ManagerSpaceEditor/units/SpaceSelect.tsx new file mode 100644 index 000000000..33306a646 --- /dev/null +++ b/frontend/src/pages/ManagerSpaceEditor/units/SpaceSelect.tsx @@ -0,0 +1,59 @@ +import { Dispatch, ReactNode, SetStateAction, useMemo } from 'react'; +import ColorDot from 'components/ColorDot/ColorDot'; +import Select from 'components/Select/Select'; +import useFormContext from 'hooks/useFormContext'; +import { ManagerSpace } from 'types/common'; +import { sortSpaces } from 'utils/sort'; +import { SpaceFormContext } from '../providers/SpaceFormProvider'; +import * as Styled from './SpaceSelect.styles'; + +interface Props { + spaces: ManagerSpace[]; + selectedSpaceIdState: [number | null, Dispatch>]; + disabled: boolean; + children: ReactNode; +} + +const SpaceSelect = ({ spaces, selectedSpaceIdState, disabled, children }: Props): JSX.Element => { + const [selectedSpaceId, setSelectedSpaceId] = selectedSpaceIdState; + + const { updateWithSpace } = useFormContext(SpaceFormContext); + + const sortedSpaces = useMemo(() => sortSpaces(spaces), [spaces]); + + const spaceOptions = sortedSpaces.map(({ id, name, color }) => ({ + value: `${id}`, + children: ( + + + {name} + + ), + })); + + const handleChangeSpace = (spaceId: number) => { + const selectedSpace = spaces.find((space) => space.id === spaceId); + + updateWithSpace(selectedSpace as ManagerSpace); + setSelectedSpaceId(spaceId); + }; + + return ( + + 공간 선택 + +