From 65bdfb338c394ba4cea5b589669b657ee7fecc95 Mon Sep 17 00:00:00 2001 From: Yeongseo Na Date: Tue, 8 Oct 2024 16:36:39 +0900 Subject: [PATCH 01/49] =?UTF-8?q?[BE]=20docs:=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EB=AA=A8=EC=95=84=EB=B3=B4=EA=B8=B0=20RestDocs=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1=20(#785)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 실제와 다른 부분 api docs 에서 수정 - CHECKBOX이면, answer(서술형 답변)이 null 이어야 한다. * refactor: 함수 이름 변경 - 이제 세션을 사용하는 것이 기본 값이므로, 다른 함수들과 형식을 통일한다. * feat: 리뷰 요약 응답 dto 생성 * feat: 리뷰 요약 컨트롤러 메서드, 서비스 코드 작성 - 컴파일 에러만 안나게 해둔 상태 * test: 리뷰 요약 API 테스트 작성 * feat: 섹션 목록 응답 dto 생성 * feat: 섹션 목록 조회 컨트롤러, 서비스 코드 작성 * test: 섹션 목록 조회 API 테스트 * docs: RestDocs 에 추가 * feat: 섹션 별 리뷰 응답 dto 생성 * feat: 섹션 별 리뷰 조회 컨트롤러, 서비스 코드 작성 * test: 섹션 별 리뷰 조회 API 테스트 * docs: RestDocs 에 추가 * refactor: dto 이름 변경 - 복수의 리뷰들이 대한 이름이므로, s를 붙이도록 * refactor: 인자 순서 변경 * refactor: 응답 dto 이름 변경 - 하나의 모듈에는 되도록 같은 이름의 클래스가 없도록 * refactor: 응답 dto 이름 변경 - GatheredReviewsResponse -> ReviewsGatherBySectionResponse - GatheredReviewResponse -> ReviewsGatherByQuestionResponse * fix: API 테스트 수정 - DTO 인자 순서 변경 반영 * refactor: Nested 클래스 이름 영어로 변경 * refactor: Nested 클래스 이름 대문자로 변경 * docs: 쿠키 설명 변경 - 세션 쿠키 -> 세션 ID * docs: 문서 구조 변경 - 모이보기 파트 따로 분리 * refactor: dto 이름 수정 - GatherBy -> GatheredBy * docs: 응답 내용에 대한 설명 보충 --- backend/src/docs/asciidoc/index.adoc | 7 ++ backend/src/docs/asciidoc/review-gather.adoc | 7 ++ backend/src/docs/asciidoc/review-summary.adoc | 3 + .../review/controller/ReviewController.java | 24 +++++ .../service/GatheredReviewLookupService.java | 14 +++ .../review/service/ReviewSummaryService.java | 12 +++ .../gathered/AnswerContentResponse.java | 6 ++ .../ReviewsGatheredByQuestionResponse.java | 15 +++ .../ReviewsGatheredBySectionResponse.java | 8 ++ .../gathered/SimpleQuestionResponse.java | 9 ++ .../dto/response/gathered/VoteResponse.java | 7 ++ .../list/ReceivedReviewsSummaryResponse.java | 8 ++ .../controller/SectionController.java | 24 +++++ .../template/service/SectionService.java | 16 ++++ .../dto/response/SectionNameResponse.java | 7 ++ .../dto/response/SectionNamesResponse.java | 8 ++ .../src/test/java/reviewme/api/ApiTest.java | 16 +++- .../test/java/reviewme/api/ReviewApiTest.java | 94 ++++++++++++++++++- .../java/reviewme/api/TemplateApiTest.java | 36 +++++++ .../java/reviewme/api/TemplateFixture.java | 2 +- .../repository/ReviewRepositoryTest.java | 7 +- .../ReviewDetailLookupServiceTest.java | 4 +- 22 files changed, 326 insertions(+), 8 deletions(-) create mode 100644 backend/src/docs/asciidoc/review-gather.adoc create mode 100644 backend/src/docs/asciidoc/review-summary.adoc create mode 100644 backend/src/main/java/reviewme/review/service/GatheredReviewLookupService.java create mode 100644 backend/src/main/java/reviewme/review/service/ReviewSummaryService.java create mode 100644 backend/src/main/java/reviewme/review/service/dto/response/gathered/AnswerContentResponse.java create mode 100644 backend/src/main/java/reviewme/review/service/dto/response/gathered/ReviewsGatheredByQuestionResponse.java create mode 100644 backend/src/main/java/reviewme/review/service/dto/response/gathered/ReviewsGatheredBySectionResponse.java create mode 100644 backend/src/main/java/reviewme/review/service/dto/response/gathered/SimpleQuestionResponse.java create mode 100644 backend/src/main/java/reviewme/review/service/dto/response/gathered/VoteResponse.java create mode 100644 backend/src/main/java/reviewme/review/service/dto/response/list/ReceivedReviewsSummaryResponse.java create mode 100644 backend/src/main/java/reviewme/template/controller/SectionController.java create mode 100644 backend/src/main/java/reviewme/template/service/SectionService.java create mode 100644 backend/src/main/java/reviewme/template/service/dto/response/SectionNameResponse.java create mode 100644 backend/src/main/java/reviewme/template/service/dto/response/SectionNamesResponse.java diff --git a/backend/src/docs/asciidoc/index.adoc b/backend/src/docs/asciidoc/index.adoc index d45cb9711..616bf03bb 100644 --- a/backend/src/docs/asciidoc/index.adoc +++ b/backend/src/docs/asciidoc/index.adoc @@ -22,6 +22,9 @@ include::create-review.adoc[] == 리뷰 조회 +=== 리뷰 요약 조회 +include::review-summary.adoc[] + === 리뷰 단건 조회 include::review-detail.adoc[] @@ -29,3 +32,7 @@ include::review-detail.adoc[] === 리뷰 목록 조회 include::review-list.adoc[] + +=== 리뷰 모아보기 + +include::review-gather.adoc[] \ No newline at end of file diff --git a/backend/src/docs/asciidoc/review-gather.adoc b/backend/src/docs/asciidoc/review-gather.adoc new file mode 100644 index 000000000..6adabddc7 --- /dev/null +++ b/backend/src/docs/asciidoc/review-gather.adoc @@ -0,0 +1,7 @@ +==== 섹션 이름 목록 가져오기 + +operation::get-session-names[snippets="curl-request,request-cookies,http-response,response-fields"] + +==== 받은 리뷰 섹션별 모아보기 + +operation::received-review-by-section[snippets="curl-request,request-cookies,query-parameters,http-response,response-fields"] diff --git a/backend/src/docs/asciidoc/review-summary.adoc b/backend/src/docs/asciidoc/review-summary.adoc new file mode 100644 index 000000000..f17b64ec6 --- /dev/null +++ b/backend/src/docs/asciidoc/review-summary.adoc @@ -0,0 +1,3 @@ +==== 자신이 받은 리뷰 요약 조회 + +operation::received-review-summary[snippets="curl-request,request-cookies,http-response,response-fields"] diff --git a/backend/src/main/java/reviewme/review/controller/ReviewController.java b/backend/src/main/java/reviewme/review/controller/ReviewController.java index c1485f678..ba237ddde 100644 --- a/backend/src/main/java/reviewme/review/controller/ReviewController.java +++ b/backend/src/main/java/reviewme/review/controller/ReviewController.java @@ -11,11 +11,15 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.SessionAttribute; +import reviewme.review.service.GatheredReviewLookupService; import reviewme.review.service.ReviewDetailLookupService; import reviewme.review.service.ReviewListLookupService; import reviewme.review.service.ReviewRegisterService; +import reviewme.review.service.ReviewSummaryService; import reviewme.review.service.dto.request.ReviewRegisterRequest; import reviewme.review.service.dto.response.detail.ReviewDetailResponse; +import reviewme.review.service.dto.response.gathered.ReviewsGatheredBySectionResponse; +import reviewme.review.service.dto.response.list.ReceivedReviewsSummaryResponse; import reviewme.review.service.dto.response.list.ReceivedReviewsResponse; @RestController @@ -27,6 +31,8 @@ public class ReviewController { private final ReviewRegisterService reviewRegisterService; private final ReviewListLookupService reviewListLookupService; private final ReviewDetailLookupService reviewDetailLookupService; + private final ReviewSummaryService reviewSummaryService; + private final GatheredReviewLookupService gatheredReviewLookupService; @PostMapping("/v2/reviews") public ResponseEntity createReview(@Valid @RequestBody ReviewRegisterRequest request) { @@ -53,4 +59,22 @@ public ResponseEntity findReceivedReviewDetail( ReviewDetailResponse response = reviewDetailLookupService.getReviewDetail(id, reviewRequestCode); return ResponseEntity.ok(response); } + + @GetMapping("/v2/reviews/summary") + public ResponseEntity findReceivedReviewOverview( + @SessionAttribute("reviewRequestCode") String reviewRequestCode + ) { + ReceivedReviewsSummaryResponse response = reviewSummaryService.getReviewSummary(reviewRequestCode); + return ResponseEntity.ok(response); + } + + @GetMapping("/v2/reviews/gather") + public ResponseEntity getReceivedReviewsBySectionId( + @RequestParam("sectionId") Long sectionId, + @SessionAttribute("reviewRequestCode") String reviewRequestCode + ) { + ReviewsGatheredBySectionResponse response = gatheredReviewLookupService.getReceivedReviewsBySectionId( + reviewRequestCode, sectionId); + return ResponseEntity.ok(response); + } } diff --git a/backend/src/main/java/reviewme/review/service/GatheredReviewLookupService.java b/backend/src/main/java/reviewme/review/service/GatheredReviewLookupService.java new file mode 100644 index 000000000..a48e59525 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/GatheredReviewLookupService.java @@ -0,0 +1,14 @@ +package reviewme.review.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import reviewme.review.service.dto.response.gathered.ReviewsGatheredBySectionResponse; + +@Service +@RequiredArgsConstructor +public class GatheredReviewLookupService { + + public ReviewsGatheredBySectionResponse getReceivedReviewsBySectionId(String reviewRequestCode, long sectionId) { + return null; + } +} diff --git a/backend/src/main/java/reviewme/review/service/ReviewSummaryService.java b/backend/src/main/java/reviewme/review/service/ReviewSummaryService.java new file mode 100644 index 000000000..d556ef5c1 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/ReviewSummaryService.java @@ -0,0 +1,12 @@ +package reviewme.review.service; + +import org.springframework.stereotype.Service; +import reviewme.review.service.dto.response.list.ReceivedReviewsSummaryResponse; + +@Service +public class ReviewSummaryService { + + public ReceivedReviewsSummaryResponse getReviewSummary(String reviewRequestCode) { + return null; + } +} diff --git a/backend/src/main/java/reviewme/review/service/dto/response/gathered/AnswerContentResponse.java b/backend/src/main/java/reviewme/review/service/dto/response/gathered/AnswerContentResponse.java new file mode 100644 index 000000000..df73eb56c --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/dto/response/gathered/AnswerContentResponse.java @@ -0,0 +1,6 @@ +package reviewme.review.service.dto.response.gathered; + +public record AnswerContentResponse( + String content +) { +} diff --git a/backend/src/main/java/reviewme/review/service/dto/response/gathered/ReviewsGatheredByQuestionResponse.java b/backend/src/main/java/reviewme/review/service/dto/response/gathered/ReviewsGatheredByQuestionResponse.java new file mode 100644 index 000000000..9658e80d9 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/dto/response/gathered/ReviewsGatheredByQuestionResponse.java @@ -0,0 +1,15 @@ +package reviewme.review.service.dto.response.gathered; + +import jakarta.annotation.Nullable; +import java.util.List; + +public record ReviewsGatheredByQuestionResponse( + SimpleQuestionResponse question, + + @Nullable + List answers, + + @Nullable + List votes +) { +} diff --git a/backend/src/main/java/reviewme/review/service/dto/response/gathered/ReviewsGatheredBySectionResponse.java b/backend/src/main/java/reviewme/review/service/dto/response/gathered/ReviewsGatheredBySectionResponse.java new file mode 100644 index 000000000..9c0f0d76a --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/dto/response/gathered/ReviewsGatheredBySectionResponse.java @@ -0,0 +1,8 @@ +package reviewme.review.service.dto.response.gathered; + +import java.util.List; + +public record ReviewsGatheredBySectionResponse( + List reviews +) { +} diff --git a/backend/src/main/java/reviewme/review/service/dto/response/gathered/SimpleQuestionResponse.java b/backend/src/main/java/reviewme/review/service/dto/response/gathered/SimpleQuestionResponse.java new file mode 100644 index 000000000..1c9618a72 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/dto/response/gathered/SimpleQuestionResponse.java @@ -0,0 +1,9 @@ +package reviewme.review.service.dto.response.gathered; + +import reviewme.question.domain.QuestionType; + +public record SimpleQuestionResponse( + String name, + QuestionType type +) { +} diff --git a/backend/src/main/java/reviewme/review/service/dto/response/gathered/VoteResponse.java b/backend/src/main/java/reviewme/review/service/dto/response/gathered/VoteResponse.java new file mode 100644 index 000000000..57ba21e0d --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/dto/response/gathered/VoteResponse.java @@ -0,0 +1,7 @@ +package reviewme.review.service.dto.response.gathered; + +public record VoteResponse( + String content, + int count +) { +} diff --git a/backend/src/main/java/reviewme/review/service/dto/response/list/ReceivedReviewsSummaryResponse.java b/backend/src/main/java/reviewme/review/service/dto/response/list/ReceivedReviewsSummaryResponse.java new file mode 100644 index 000000000..cd63fe48c --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/dto/response/list/ReceivedReviewsSummaryResponse.java @@ -0,0 +1,8 @@ +package reviewme.review.service.dto.response.list; + +public record ReceivedReviewsSummaryResponse( + String projectName, + String revieweeName, + int totalReviewCount +) { +} diff --git a/backend/src/main/java/reviewme/template/controller/SectionController.java b/backend/src/main/java/reviewme/template/controller/SectionController.java new file mode 100644 index 000000000..dccd61468 --- /dev/null +++ b/backend/src/main/java/reviewme/template/controller/SectionController.java @@ -0,0 +1,24 @@ +package reviewme.template.controller; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.SessionAttribute; +import reviewme.template.service.SectionService; +import reviewme.template.service.dto.response.SectionNamesResponse; + +@RestController +@RequiredArgsConstructor +public class SectionController { + + private final SectionService sectionService; + + @GetMapping("/v2/sections") + public ResponseEntity getSectionNames( + @SessionAttribute("reviewRequestCode") String reviewRequestCode + ) { + SectionNamesResponse sectionNames = sectionService.getSectionNames(reviewRequestCode); + return ResponseEntity.ok(sectionNames); + } +} diff --git a/backend/src/main/java/reviewme/template/service/SectionService.java b/backend/src/main/java/reviewme/template/service/SectionService.java new file mode 100644 index 000000000..a9c5fb4fa --- /dev/null +++ b/backend/src/main/java/reviewme/template/service/SectionService.java @@ -0,0 +1,16 @@ +package reviewme.template.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import reviewme.template.service.dto.response.SectionNamesResponse; + +@Service +@RequiredArgsConstructor +public class SectionService { + + @Transactional(readOnly = true) + public SectionNamesResponse getSectionNames(String reviewRequestCode) { + return null; + } +} diff --git a/backend/src/main/java/reviewme/template/service/dto/response/SectionNameResponse.java b/backend/src/main/java/reviewme/template/service/dto/response/SectionNameResponse.java new file mode 100644 index 000000000..62c84db28 --- /dev/null +++ b/backend/src/main/java/reviewme/template/service/dto/response/SectionNameResponse.java @@ -0,0 +1,7 @@ +package reviewme.template.service.dto.response; + +public record SectionNameResponse( + long id, + String name +) { +} diff --git a/backend/src/main/java/reviewme/template/service/dto/response/SectionNamesResponse.java b/backend/src/main/java/reviewme/template/service/dto/response/SectionNamesResponse.java new file mode 100644 index 000000000..6b1fae53c --- /dev/null +++ b/backend/src/main/java/reviewme/template/service/dto/response/SectionNamesResponse.java @@ -0,0 +1,8 @@ +package reviewme.template.service.dto.response; + +import java.util.List; + +public record SectionNamesResponse( + List sections +) { +} diff --git a/backend/src/test/java/reviewme/api/ApiTest.java b/backend/src/test/java/reviewme/api/ApiTest.java index e2b884706..94a0e15e8 100644 --- a/backend/src/test/java/reviewme/api/ApiTest.java +++ b/backend/src/test/java/reviewme/api/ApiTest.java @@ -27,19 +27,24 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.web.context.WebApplicationContext; import reviewme.review.controller.ReviewController; +import reviewme.review.service.GatheredReviewLookupService; import reviewme.review.service.ReviewDetailLookupService; import reviewme.review.service.ReviewListLookupService; import reviewme.review.service.ReviewRegisterService; +import reviewme.review.service.ReviewSummaryService; import reviewme.reviewgroup.controller.ReviewGroupController; import reviewme.reviewgroup.service.ReviewGroupLookupService; import reviewme.reviewgroup.service.ReviewGroupService; +import reviewme.template.controller.SectionController; import reviewme.template.controller.TemplateController; +import reviewme.template.service.SectionService; import reviewme.template.service.TemplateService; @WebMvcTest({ ReviewGroupController.class, ReviewController.class, - TemplateController.class + TemplateController.class, + SectionController.class }) @ExtendWith(RestDocumentationExtension.class) public abstract class ApiTest { @@ -64,6 +69,15 @@ public abstract class ApiTest { @MockBean protected ReviewGroupLookupService reviewGroupLookupService; + @MockBean + protected ReviewSummaryService reviewSummaryService; + + @MockBean + protected SectionService sectionService; + + @MockBean + protected GatheredReviewLookupService gatheredReviewLookupService; + Filter sessionCookieFilter = (request, response, chain) -> { chain.doFilter(request, response); HttpSession session = ((HttpServletRequest) request).getSession(false); diff --git a/backend/src/test/java/reviewme/api/ReviewApiTest.java b/backend/src/test/java/reviewme/api/ReviewApiTest.java index 46a6dace6..e8ae73a08 100644 --- a/backend/src/test/java/reviewme/api/ReviewApiTest.java +++ b/backend/src/test/java/reviewme/api/ReviewApiTest.java @@ -22,7 +22,14 @@ import org.springframework.restdocs.mockmvc.RestDocumentationResultHandler; import org.springframework.restdocs.payload.FieldDescriptor; import org.springframework.restdocs.request.ParameterDescriptor; +import reviewme.question.domain.QuestionType; import reviewme.review.service.dto.request.ReviewRegisterRequest; +import reviewme.review.service.dto.response.gathered.AnswerContentResponse; +import reviewme.review.service.dto.response.gathered.ReviewsGatheredByQuestionResponse; +import reviewme.review.service.dto.response.gathered.ReviewsGatheredBySectionResponse; +import reviewme.review.service.dto.response.gathered.SimpleQuestionResponse; +import reviewme.review.service.dto.response.gathered.VoteResponse; +import reviewme.review.service.dto.response.list.ReceivedReviewsSummaryResponse; import reviewme.review.service.dto.response.list.ReceivedReviewsResponse; import reviewme.review.service.dto.response.list.ReviewCategoryResponse; import reviewme.review.service.dto.response.list.ReviewListElementResponse; @@ -101,7 +108,7 @@ class ReviewApiTest extends ApiTest { } @Test - void 세션으로_자신이_받은_리뷰_한_개를_조회한다() { + void 자신이_받은_리뷰_한_개를_조회한다() { BDDMockito.given(reviewDetailLookupService.getReviewDetail(anyLong(), anyString())) .willReturn(TemplateFixture.templateAnswerResponse()); @@ -110,7 +117,7 @@ class ReviewApiTest extends ApiTest { }; CookieDescriptor[] cookieDescriptors = { - cookieWithName("JSESSIONID").description("세션 쿠키") + cookieWithName("JSESSIONID").description("세션 ID") }; FieldDescriptor[] responseFieldDescriptors = { @@ -171,7 +178,7 @@ class ReviewApiTest extends ApiTest { .willReturn(response); CookieDescriptor[] cookieDescriptors = { - cookieWithName("JSESSIONID").description("세션 쿠키") + cookieWithName("JSESSIONID").description("세션 ID") }; ParameterDescriptor[] queryParameter = { @@ -213,4 +220,85 @@ class ReviewApiTest extends ApiTest { .apply(handler) .statusCode(200); } + + @Test + void 자신이_받은_리뷰의_요약를_조회한다() { + BDDMockito.given(reviewSummaryService.getReviewSummary(anyString())) + .willReturn(new ReceivedReviewsSummaryResponse("리뷰미", "산초", 5)); + + CookieDescriptor[] cookieDescriptors = { + cookieWithName("JSESSIONID").description("세션 ID") + }; + + FieldDescriptor[] responseFieldDescriptors = { + fieldWithPath("projectName").description("프로젝트 이름"), + fieldWithPath("revieweeName").description("리뷰어 이름"), + fieldWithPath("totalReviewCount").description("받은 리뷰 전체 개수") + }; + + RestDocumentationResultHandler handler = document( + "received-review-summary", + requestCookies(cookieDescriptors), + responseFields(responseFieldDescriptors) + ); + + givenWithSpec().log().all() + .cookie("JSESSIONID", "ABCDEFGHI1234") + .when().get("/v2/reviews/summary") + .then().log().all() + .apply(handler) + .statusCode(200); + } + + @Test + void 자신이_받은_리뷰의_요약를_섹션별로_조회한다() { + ReviewsGatheredBySectionResponse response = new ReviewsGatheredBySectionResponse(List.of( + new ReviewsGatheredByQuestionResponse( + new SimpleQuestionResponse("서술형 질문", QuestionType.TEXT), + List.of( + new AnswerContentResponse("산초의 답변"), + new AnswerContentResponse("삼촌의 답변")), + null), + new ReviewsGatheredByQuestionResponse( + new SimpleQuestionResponse("선택형 질문", QuestionType.CHECKBOX), + null, + List.of( + new VoteResponse("짜장", 3), + new VoteResponse("짬뽕", 5)))) + ); + BDDMockito.given(gatheredReviewLookupService.getReceivedReviewsBySectionId(anyString(), anyLong())) + .willReturn(response); + + CookieDescriptor[] cookieDescriptors = { + cookieWithName("JSESSIONID").description("세션 ID") + }; + ParameterDescriptor[] queryParameterDescriptors = { + parameterWithName("sectionId").description("섹션 ID") + }; + FieldDescriptor[] responseFieldDescriptors = { + fieldWithPath("reviews").description("리뷰 목록"), + fieldWithPath("reviews[].question").description("질문 정보"), + fieldWithPath("reviews[].question.name").description("질문 이름"), + fieldWithPath("reviews[].question.type").description("질문 유형"), + fieldWithPath("reviews[].answers").description("서술형 답변 목록 - question.type이 TEXT가 아니면 null").optional(), + fieldWithPath("reviews[].answers[].content").description("서술형 답변 내용"), + fieldWithPath("reviews[].votes").description("객관식 답변 목록 - question.type이 CHECKBOX가 아니면 null").optional(), + fieldWithPath("reviews[].votes[].content").description("객관식 항목"), + fieldWithPath("reviews[].votes[].count").description("선택한 사람 수"), + }; + RestDocumentationResultHandler handler = document( + "received-review-by-section", + requestCookies(cookieDescriptors), + queryParameters(queryParameterDescriptors), + responseFields(responseFieldDescriptors) + ); + + givenWithSpec().log().all() + .cookie("JSESSIONID", "ABCDEFGHI1234") + .queryParam("sectionId", 1) + .when().get("/v2/reviews/gather") + .then().log().all() + .apply(handler) + .statusCode(200); + } } diff --git a/backend/src/test/java/reviewme/api/TemplateApiTest.java b/backend/src/test/java/reviewme/api/TemplateApiTest.java index 510a894e0..2fd462223 100644 --- a/backend/src/test/java/reviewme/api/TemplateApiTest.java +++ b/backend/src/test/java/reviewme/api/TemplateApiTest.java @@ -1,18 +1,24 @@ package reviewme.api; import static org.mockito.ArgumentMatchers.anyString; +import static org.springframework.restdocs.cookies.CookieDocumentation.cookieWithName; +import static org.springframework.restdocs.cookies.CookieDocumentation.requestCookies; import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; +import java.util.List; import org.junit.jupiter.api.Test; import org.mockito.BDDMockito; +import org.springframework.restdocs.cookies.CookieDescriptor; import org.springframework.restdocs.mockmvc.RestDocumentationResultHandler; import org.springframework.restdocs.payload.FieldDescriptor; import org.springframework.restdocs.request.ParameterDescriptor; import reviewme.review.service.exception.ReviewGroupNotFoundByReviewRequestCodeException; +import reviewme.template.service.dto.response.SectionNameResponse; +import reviewme.template.service.dto.response.SectionNamesResponse; class TemplateApiTest extends ApiTest { @@ -90,4 +96,34 @@ class TemplateApiTest extends ApiTest { .apply(handler) .statusCode(404); } + + @Test + void 섹션_이름을_반환한다() { + SectionNamesResponse response = new SectionNamesResponse(List.of( + new SectionNameResponse(1, "섹션1 이름"), + new SectionNameResponse(2, "섹션2 이름") + )); + BDDMockito.given(sectionService.getSectionNames(anyString())) + .willReturn(response); + + CookieDescriptor[] cookieDescriptors = { + cookieWithName("JSESSIONID").description("세션 ID") + }; + FieldDescriptor[] responseFieldDescriptors = { + fieldWithPath("sections[]").description("섹션 목록"), + fieldWithPath("sections[].name").description("섹션 이름"), + fieldWithPath("sections[].id").description("섹션 ID") + }; + RestDocumentationResultHandler handler = document( + "get-session-names", + requestCookies(cookieDescriptors), + responseFields(responseFieldDescriptors) + ); + givenWithSpec().log().all() + .cookie("JSESSIONID", "ABCDEFGHI1234") + .when().get("/v2/sections") + .then().log().all() + .apply(handler) + .statusCode(200); + } } diff --git a/backend/src/test/java/reviewme/api/TemplateFixture.java b/backend/src/test/java/reviewme/api/TemplateFixture.java index a4a941985..aba719fbb 100644 --- a/backend/src/test/java/reviewme/api/TemplateFixture.java +++ b/backend/src/test/java/reviewme/api/TemplateFixture.java @@ -96,7 +96,7 @@ public static ReviewDetailResponse templateAnswerResponse() { OptionGroupAnswerResponse secondOptionGroupAnswer = new OptionGroupAnswerResponse(2, 1, 3, secondOptionAnswers); QuestionAnswerResponse secondQuestionAnswer = new QuestionAnswerResponse( 2, true, QuestionType.CHECKBOX, "커뮤니케이션, 협업 능력에서 어떤 부분이 인상 깊었는지 선택해주세요.", secondOptionGroupAnswer, - "아루는 커뮤니케이션과 협업 능력에서 인상깊었어요~" + null ); SectionAnswerResponse secondSectionAnswer = new SectionAnswerResponse( 2, "커뮤니케이션, 협업 능력에서 어떤 부분이 인상 깊었는지 선택해주세요.", List.of(secondQuestionAnswer) diff --git a/backend/src/test/java/reviewme/review/repository/ReviewRepositoryTest.java b/backend/src/test/java/reviewme/review/repository/ReviewRepositoryTest.java index c457ce4cd..2149c7ed9 100644 --- a/backend/src/test/java/reviewme/review/repository/ReviewRepositoryTest.java +++ b/backend/src/test/java/reviewme/review/repository/ReviewRepositoryTest.java @@ -8,6 +8,7 @@ import java.time.LocalDate; import java.util.List; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -62,7 +63,8 @@ class ReviewRepositoryTest { } @Nested - class 리뷰그룹_아이디에_해당하는_리뷰를_생성일_기준_내림차순으로_페이징하여_불러온다 { + @DisplayName("리뷰 그룹 아이디에 해당하는 리뷰를 생성일 기준 내림차순으로 페이징하여 불러온다") + class FindByReviewGroupIdWithLimit { private final Question question = questionRepository.save(서술형_필수_질문()); private final Section section = sectionRepository.save(항상_보이는_섹션(List.of(question.getId()))); @@ -156,7 +158,8 @@ class 리뷰그룹_아이디에_해당하는_리뷰를_생성일_기준_내림 } @Nested - class 주어진_리뷰보다_오래된_리뷰가_있는지_검사한다 { + @DisplayName("주어진 리뷰보다 오래된 리뷰가 있는지 검사한다") + class ExistsOlderReviewInReviewGroup { Question question = questionRepository.save(서술형_필수_질문()); Section section = sectionRepository.save(항상_보이는_섹션(List.of(question.getId()))); diff --git a/backend/src/test/java/reviewme/review/service/ReviewDetailLookupServiceTest.java b/backend/src/test/java/reviewme/review/service/ReviewDetailLookupServiceTest.java index 02d138f0b..94f281594 100644 --- a/backend/src/test/java/reviewme/review/service/ReviewDetailLookupServiceTest.java +++ b/backend/src/test/java/reviewme/review/service/ReviewDetailLookupServiceTest.java @@ -13,6 +13,7 @@ import static reviewme.fixture.TemplateFixture.템플릿; import java.util.List; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -143,7 +144,8 @@ class ReviewDetailLookupServiceTest { } @Nested - class 필수가_아닌_답변에_응답하지_않았을_때 { + @DisplayName("필수가 아닌 답변에 응답하지 않았을 때") + class NotAnsweredOptionalQuestion { @Test void 섹션에_필수가_아닌_질문만_있다면_섹션_자체를_반환하지_않는다() { From 1c5b17752a1a9d75152d47ddea42652d1e168203 Mon Sep 17 00:00:00 2001 From: Donghoon Lee Date: Thu, 10 Oct 2024 10:08:34 +0900 Subject: [PATCH 02/49] =?UTF-8?q?[BE]=20docs:=20=EB=8B=B5=EB=B3=80=20?= =?UTF-8?q?=ED=95=98=EC=9D=B4=EB=9D=BC=EC=9D=B4=ED=8A=B8=20API=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EC=9E=91=EC=84=B1=20(#800)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 하이라이트 요청 dto 생성 * feat: 하이라이트 뼈대 코드 작성 * docs: API 문서 작성 * docs: request cookie 필드 추가 * fix: 재귀적 `@Valid` 적용 * fix: 세션 값 추가 * refactor: API에 질문 ID 필드 추가 * refactor: 비어있지 않으면 안 되는 부분들에 대한 검증 추가 --- .../src/docs/asciidoc/highlight-answers.adoc | 3 + backend/src/docs/asciidoc/index.adoc | 6 +- .../controller/HighlightController.java | 25 ++++++++ .../highlight/service/HighlightService.java | 12 ++++ .../dto/HighlightIndexRangeRequest.java | 13 ++++ .../service/dto/HighlightRequest.java | 16 +++++ .../service/dto/HighlightedLineRequest.java | 16 +++++ .../service/dto/HighlightsRequest.java | 15 +++++ .../src/test/java/reviewme/api/ApiTest.java | 8 ++- .../java/reviewme/api/HighlightApiTest.java | 62 +++++++++++++++++++ 10 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 backend/src/docs/asciidoc/highlight-answers.adoc create mode 100644 backend/src/main/java/reviewme/highlight/controller/HighlightController.java create mode 100644 backend/src/main/java/reviewme/highlight/service/HighlightService.java create mode 100644 backend/src/main/java/reviewme/highlight/service/dto/HighlightIndexRangeRequest.java create mode 100644 backend/src/main/java/reviewme/highlight/service/dto/HighlightRequest.java create mode 100644 backend/src/main/java/reviewme/highlight/service/dto/HighlightedLineRequest.java create mode 100644 backend/src/main/java/reviewme/highlight/service/dto/HighlightsRequest.java create mode 100644 backend/src/test/java/reviewme/api/HighlightApiTest.java diff --git a/backend/src/docs/asciidoc/highlight-answers.adoc b/backend/src/docs/asciidoc/highlight-answers.adoc new file mode 100644 index 000000000..7f0fd988b --- /dev/null +++ b/backend/src/docs/asciidoc/highlight-answers.adoc @@ -0,0 +1,3 @@ +==== 리뷰 하이라이트 변경 (추가, 삭제, 수정 포함) + +operation::highlight-answer[snippets="curl-request,request-cookies,http-response,request-fields"] diff --git a/backend/src/docs/asciidoc/index.adoc b/backend/src/docs/asciidoc/index.adoc index 616bf03bb..4d67754a7 100644 --- a/backend/src/docs/asciidoc/index.adoc +++ b/backend/src/docs/asciidoc/index.adoc @@ -35,4 +35,8 @@ include::review-list.adoc[] === 리뷰 모아보기 -include::review-gather.adoc[] \ No newline at end of file +include::review-gather.adoc[] + +=== 답변 하이라이트 + +include::highlight-answers.adoc[] diff --git a/backend/src/main/java/reviewme/highlight/controller/HighlightController.java b/backend/src/main/java/reviewme/highlight/controller/HighlightController.java new file mode 100644 index 000000000..8a9a7f0b2 --- /dev/null +++ b/backend/src/main/java/reviewme/highlight/controller/HighlightController.java @@ -0,0 +1,25 @@ +package reviewme.highlight.controller; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +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.RestController; +import org.springframework.web.bind.annotation.SessionAttribute; +import reviewme.highlight.service.HighlightService; +import reviewme.highlight.service.dto.HighlightsRequest; + +@RestController +@RequiredArgsConstructor +public class HighlightController { + + private final HighlightService highlightService; + + @PostMapping("/v2/highlight") + public ResponseEntity highlight(@Valid @RequestBody HighlightsRequest request, + @SessionAttribute("reviewRequestCode") String reviewRequestCode) { + highlightService.highlight(request); + return ResponseEntity.ok().build(); + } +} diff --git a/backend/src/main/java/reviewme/highlight/service/HighlightService.java b/backend/src/main/java/reviewme/highlight/service/HighlightService.java new file mode 100644 index 000000000..e14ea984d --- /dev/null +++ b/backend/src/main/java/reviewme/highlight/service/HighlightService.java @@ -0,0 +1,12 @@ +package reviewme.highlight.service; + +import org.springframework.stereotype.Service; +import reviewme.highlight.service.dto.HighlightsRequest; + +@Service +public class HighlightService { + + public void highlight(HighlightsRequest request) { + // TODO: implement method + } +} diff --git a/backend/src/main/java/reviewme/highlight/service/dto/HighlightIndexRangeRequest.java b/backend/src/main/java/reviewme/highlight/service/dto/HighlightIndexRangeRequest.java new file mode 100644 index 000000000..fde581308 --- /dev/null +++ b/backend/src/main/java/reviewme/highlight/service/dto/HighlightIndexRangeRequest.java @@ -0,0 +1,13 @@ +package reviewme.highlight.service.dto; + +import jakarta.validation.constraints.NotNull; + +public record HighlightIndexRangeRequest( + + @NotNull(message = "시작 인덱스를 입력해주세요.") + Long startIndex, + + @NotNull(message = "끝 인덱스를 입력해주세요.") + Long endIndex +) { +} diff --git a/backend/src/main/java/reviewme/highlight/service/dto/HighlightRequest.java b/backend/src/main/java/reviewme/highlight/service/dto/HighlightRequest.java new file mode 100644 index 000000000..673cc8e6a --- /dev/null +++ b/backend/src/main/java/reviewme/highlight/service/dto/HighlightRequest.java @@ -0,0 +1,16 @@ +package reviewme.highlight.service.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import java.util.List; + +public record HighlightRequest( + + @NotNull(message = "답변 ID를 입력해주세요.") + Long answerId, + + @Valid @NotEmpty(message = "하이라이트 된 라인을 입력해주세요.") + List lines +) { +} diff --git a/backend/src/main/java/reviewme/highlight/service/dto/HighlightedLineRequest.java b/backend/src/main/java/reviewme/highlight/service/dto/HighlightedLineRequest.java new file mode 100644 index 000000000..a8275aea3 --- /dev/null +++ b/backend/src/main/java/reviewme/highlight/service/dto/HighlightedLineRequest.java @@ -0,0 +1,16 @@ +package reviewme.highlight.service.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import java.util.List; + +public record HighlightedLineRequest( + + @NotNull(message = "인덱스를 입력해주세요.") + Long index, + + @Valid @NotEmpty(message = "하이라이트 범위를 입력해주세요.") + List ranges +) { +} diff --git a/backend/src/main/java/reviewme/highlight/service/dto/HighlightsRequest.java b/backend/src/main/java/reviewme/highlight/service/dto/HighlightsRequest.java new file mode 100644 index 000000000..7b41526d9 --- /dev/null +++ b/backend/src/main/java/reviewme/highlight/service/dto/HighlightsRequest.java @@ -0,0 +1,15 @@ +package reviewme.highlight.service.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import java.util.List; + +public record HighlightsRequest( + + @NotNull(message = "질문 ID를 입력해주세요.") + Long questionId, + + @Valid @NotNull(message = "하이라이트할 부분을 입력해주세요.") + List highlights +) { +} diff --git a/backend/src/test/java/reviewme/api/ApiTest.java b/backend/src/test/java/reviewme/api/ApiTest.java index 94a0e15e8..2d919e645 100644 --- a/backend/src/test/java/reviewme/api/ApiTest.java +++ b/backend/src/test/java/reviewme/api/ApiTest.java @@ -26,6 +26,8 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.web.context.WebApplicationContext; +import reviewme.highlight.controller.HighlightController; +import reviewme.highlight.service.HighlightService; import reviewme.review.controller.ReviewController; import reviewme.review.service.GatheredReviewLookupService; import reviewme.review.service.ReviewDetailLookupService; @@ -44,7 +46,8 @@ ReviewGroupController.class, ReviewController.class, TemplateController.class, - SectionController.class + SectionController.class, + HighlightController.class }) @ExtendWith(RestDocumentationExtension.class) public abstract class ApiTest { @@ -78,6 +81,9 @@ public abstract class ApiTest { @MockBean protected GatheredReviewLookupService gatheredReviewLookupService; + @MockBean + protected HighlightService highlightService; + Filter sessionCookieFilter = (request, response, chain) -> { chain.doFilter(request, response); HttpSession session = ((HttpServletRequest) request).getSession(false); diff --git a/backend/src/test/java/reviewme/api/HighlightApiTest.java b/backend/src/test/java/reviewme/api/HighlightApiTest.java new file mode 100644 index 000000000..c908d828f --- /dev/null +++ b/backend/src/test/java/reviewme/api/HighlightApiTest.java @@ -0,0 +1,62 @@ +package reviewme.api; + +import static org.springframework.restdocs.cookies.CookieDocumentation.cookieWithName; +import static org.springframework.restdocs.cookies.CookieDocumentation.requestCookies; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; + +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.restdocs.cookies.CookieDescriptor; +import org.springframework.restdocs.mockmvc.RestDocumentationResultHandler; +import org.springframework.restdocs.payload.FieldDescriptor; + +class HighlightApiTest extends ApiTest { + + @Test + void 존재하는_답변에_하이라이트를_생성한다() { + String request = """ + { + "questionId": 1, + "highlights": [{ + "answerId": 3, + "lines": [{ + "index": 5, + "ranges": [{ + "startIndex": 6, + "endIndex": 9 + }] + }] + }] + } + """; + + CookieDescriptor[] cookieDescriptors = { + cookieWithName("JSESSIONID").description("세션 ID") + }; + + FieldDescriptor[] requestFields = { + fieldWithPath("questionId").description("질문 ID"), + fieldWithPath("highlights").description("하이라이트 목록"), + fieldWithPath("highlights[].answerId").description("답변 ID"), + fieldWithPath("highlights[].lines[].index").description("개행으로 구분되는 라인 번호, 0-based"), + fieldWithPath("highlights[].lines[].ranges[].startIndex").description("하이라이트 시작 인덱스, 0-based"), + fieldWithPath("highlights[].lines[].ranges[].endIndex").description("하이라이트 끝 인덱스, 0-based") + }; + + RestDocumentationResultHandler handler = document( + "highlight-answer", + requestFields(requestFields), + requestCookies(cookieDescriptors) + ); + + givenWithSpec().log().all() + .cookie("JSESSIONID", "AVEBNKLCL13TNVZ") + .body(request) + .when().post("/v2/highlight") + .then().log().all() + .apply(handler) + .status(HttpStatus.OK); + } +} From 9b976e65197dc63e588f95028557f79770612a4a Mon Sep 17 00:00:00 2001 From: ImxYJL <111052302+ImxYJL@users.noreply.github.com> Date: Thu, 10 Oct 2024 13:57:02 +0900 Subject: [PATCH 03/49] =?UTF-8?q?[FE]=20feat:=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D/=EB=A6=AC=EB=B7=B0=20=EB=AA=A8=EC=95=84?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=EC=9D=98=20=EA=B3=B5=ED=86=B5=20UI=20?= =?UTF-8?q?=EB=B0=8F=20=EC=8A=A4=EC=9C=84=EC=B9=98=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8,=20=EC=83=88=20URL=20=EB=B0=8F=20=EB=94=94?= =?UTF-8?q?=EC=9E=90=EC=9D=B8=20=EC=A0=81=EC=9A=A9=20(#776)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Switch 컴포넌트 제작 * chore: Switch 컴포넌트 이름 변경 * feat: 리뷰 모아보기에 대한 라우팅 추가 및 임시 페이지 구현 * feat: 리뷰 모아보기와 리뷰 목록 페이지의 공통 레이아웃 제작 * refactor: 공통 레이아웃 제작에 따른 ReviewList 리팩토링 * feat: 리뷰 목록 반응형 적용 * feat: OptionSwitch 반응형 적용 * refactor: ReviewDisplayLayout 반응형 수정 * fix: px을 rem으로 수정 * fix: 미디어 쿼리 기준 단위를 px으로 수정 * refactor: 스타일을 위한 속성에 $처리 및 스타일 전용 인터페이스 생성 * fix: 불필요한 상속 제거 * refactor: OptionSwitch 리팩토링 - option 배열을 통한 렌더링 및 시맨틱 태그를사용 * refactor: OptionSwitch 컴포넌트 리팩토링에 따른 ReviewDisplayLayout 컴포넌트 리팩토링 및 훅 분리 * feat: 선택하지 않은 option을 hover한 경우의 추가 배경색 지정 * fix: 리뷰 작성 완료 페이지에서 breadCrumb으로 리뷰 작성 페이지로 이동하는 경로를 절대 URL 경로로 수정 --- frontend/src/components/ReviewCard/index.tsx | 25 +++++----- frontend/src/components/ReviewCard/styles.ts | 46 +++++++++--------- .../components/common/OptionSwitch/index.tsx | 36 ++++++++++++++ .../components/common/OptionSwitch/styles.ts | 48 +++++++++++++++++++ frontend/src/components/common/index.tsx | 1 + .../components/layouts/PageLayout/index.tsx | 1 - .../components/ReviewInfoSection/index.tsx | 35 ++++++++++++++ .../components/ReviewInfoSection/styles.ts | 5 +- .../hooks/useReviewDisplayLayoutOptions.ts | 33 +++++++++++++ .../layouts/ReviewDisplayLayout/index.tsx | 34 +++++++++++++ .../layouts/ReviewDisplayLayout/styles.ts | 20 ++++++++ frontend/src/constants/route.ts | 1 + frontend/src/hooks/useBreadcrumbPaths.ts | 19 +++++--- frontend/src/index.tsx | 5 ++ .../src/pages/ReviewCollectionPage/index.tsx | 25 ++++++++++ .../src/pages/ReviewCollectionPage/styles.ts | 2 + .../components/PageContents/index.tsx | 7 ++- .../components/PageContents/styles.ts | 7 --- .../components/ReviewInfoSection/index.tsx | 24 ---------- frontend/src/pages/index.tsx | 1 + 20 files changed, 294 insertions(+), 81 deletions(-) create mode 100644 frontend/src/components/common/OptionSwitch/index.tsx create mode 100644 frontend/src/components/common/OptionSwitch/styles.ts create mode 100644 frontend/src/components/layouts/ReviewDisplayLayout/components/ReviewInfoSection/index.tsx rename frontend/src/{pages/ReviewListPage => components/layouts/ReviewDisplayLayout}/components/ReviewInfoSection/styles.ts (91%) create mode 100644 frontend/src/components/layouts/ReviewDisplayLayout/hooks/useReviewDisplayLayoutOptions.ts create mode 100644 frontend/src/components/layouts/ReviewDisplayLayout/index.tsx create mode 100644 frontend/src/components/layouts/ReviewDisplayLayout/styles.ts create mode 100644 frontend/src/pages/ReviewCollectionPage/index.tsx create mode 100644 frontend/src/pages/ReviewCollectionPage/styles.ts delete mode 100644 frontend/src/pages/ReviewListPage/components/ReviewInfoSection/index.tsx diff --git a/frontend/src/components/ReviewCard/index.tsx b/frontend/src/components/ReviewCard/index.tsx index f86d8c395..f820a55a4 100644 --- a/frontend/src/components/ReviewCard/index.tsx +++ b/frontend/src/components/ReviewCard/index.tsx @@ -10,24 +10,21 @@ interface ReviewCardProps { handleClick: () => void; } -const ReviewCard = ({ projectName, createdAt, contentPreview, categories, handleClick }: ReviewCardProps) => { +const ReviewCard = ({ createdAt, contentPreview, categories, handleClick }: ReviewCardProps) => { return ( - - -
- {projectName} - {createdAt} -
-
-
+ + {contentPreview} - - {categories.map((category) => ( -
{category.content}
- ))} -
+ + + {categories.map((category) => ( +
{category.content}
+ ))} +
+ {createdAt} +
); diff --git a/frontend/src/components/ReviewCard/styles.ts b/frontend/src/components/ReviewCard/styles.ts index afa98cf55..cabca6485 100644 --- a/frontend/src/components/ReviewCard/styles.ts +++ b/frontend/src/components/ReviewCard/styles.ts @@ -4,13 +4,12 @@ import media from '@/utils/media'; export const Layout = styled.div` display: flex; - flex-direction: column; border: 0.1rem solid ${({ theme }) => theme.colors.lightGray}; - border-radius: 0.8rem; + border-radius: 1rem; &:hover { cursor: pointer; - border: 0.1rem solid ${({ theme }) => theme.colors.lightPurple}; + border: 0.15rem solid ${({ theme }) => theme.colors.primaryHover}; & > div:first-of-type { background-color: ${({ theme }) => theme.colors.lightPurple}; @@ -18,24 +17,10 @@ export const Layout = styled.div` } `; -export const Header = styled.div` - display: flex; - justify-content: space-between; - - height: 6rem; - padding: 1rem 3rem; - +export const LeftLineBorder = styled.div` + width: 5rem; background-color: ${({ theme }) => theme.colors.lightGray}; - border-radius: 0.8rem 0.8rem 0 0; -`; - -export const HeaderContent = styled.div` - display: flex; - gap: 1rem; - - img { - width: 4rem; - } + border-radius: 1rem 0 0 1rem; `; export const Title = styled.div` @@ -43,8 +28,11 @@ export const Title = styled.div` font-weight: 700; `; -export const SubTitle = styled.div` - font-size: 1.2rem; +export const Date = styled.p` + height: fit-content; + padding: 0 1rem; + font-size: 1.3rem; + background-color: ${({ theme }) => theme.colors.lightGray}; `; export const Visibility = styled.div` @@ -74,6 +62,18 @@ export const Main = styled.div` } `; +export const Footer = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + + ${media.small} { + flex-direction: column; + gap: 1.2rem; + align-items: flex-start; + } +`; + export const Keyword = styled.div` display: flex; flex-wrap: wrap; @@ -83,7 +83,7 @@ export const Keyword = styled.div` font-size: 1.4rem; ${media.small} { - gap: 1.6rem; + gap: 1.2rem; } div { diff --git a/frontend/src/components/common/OptionSwitch/index.tsx b/frontend/src/components/common/OptionSwitch/index.tsx new file mode 100644 index 000000000..7b85b495e --- /dev/null +++ b/frontend/src/components/common/OptionSwitch/index.tsx @@ -0,0 +1,36 @@ +import * as S from './styles'; + +export interface OptionSwitchStyleProps { + $isChecked: boolean; +} + +export interface OptionSwitchOption { + label: string; + isChecked: boolean; + handleOptionClick: () => void; +} + +interface OptionSwitchProps { + options: OptionSwitchOption[]; +} + +const OptionSwitch = ({ options }: OptionSwitchProps) => { + const handleSwitchClick = (index: number) => { + const clickedOption = options[index]; + if (clickedOption) clickedOption.handleOptionClick(); + }; + + return ( + + {options.map((option, index) => ( + handleSwitchClick(index)}> + + {option.label} + + + ))} + + ); +}; + +export default OptionSwitch; diff --git a/frontend/src/components/common/OptionSwitch/styles.ts b/frontend/src/components/common/OptionSwitch/styles.ts new file mode 100644 index 000000000..fb7d0fddb --- /dev/null +++ b/frontend/src/components/common/OptionSwitch/styles.ts @@ -0,0 +1,48 @@ +import styled from '@emotion/styled'; + +import media from '@/utils/media'; + +import { OptionSwitchStyleProps } from './index'; + +export const OptionSwitchContainer = styled.ul` + cursor: pointer; + + display: flex; + justify-content: space-between; + + width: 15rem; + height: 4.5rem; + padding: 0.7rem; + + background-color: ${({ theme }) => theme.colors.lightGray}; + border-radius: ${({ theme }) => theme.borderRadius.basic}; + + ${media.small} { + height: 3.5rem; + font-size: 1.2rem; + } +`; + +export const CheckboxWrapper = styled.li` + display: flex; + align-items: center; + justify-content: center; + + width: 50%; + height: 100%; + + background-color: ${({ $isChecked, theme }) => ($isChecked ? theme.colors.white : theme.colors.lightGray)}; + border-radius: ${({ theme }) => theme.borderRadius.basic}; + + transition: background-color 0.2s ease-out; + + &:hover { + background-color: ${({ $isChecked, theme }) => ($isChecked ? theme.colors.white : theme.colors.lightPurple)}; + } +`; + +export const CheckboxButton = styled.button` + user-select: none; + font-size: 1.2rem; + color: ${({ $isChecked, theme }) => ($isChecked ? theme.colors.primary : theme.colors.black)}; +`; diff --git a/frontend/src/components/common/index.tsx b/frontend/src/components/common/index.tsx index f18308ab9..48218fd2f 100644 --- a/frontend/src/components/common/index.tsx +++ b/frontend/src/components/common/index.tsx @@ -7,4 +7,5 @@ export { default as Checkbox } from './Checkbox'; export { default as CheckboxItem } from './CheckboxItem'; export { default as EyeButton } from './EyeButton'; export { default as Carousel } from './Carousel'; +export { default as OptionSwitch } from './OptionSwitch'; export * from './modals'; diff --git a/frontend/src/components/layouts/PageLayout/index.tsx b/frontend/src/components/layouts/PageLayout/index.tsx index 4987e495e..34bba823c 100644 --- a/frontend/src/components/layouts/PageLayout/index.tsx +++ b/frontend/src/components/layouts/PageLayout/index.tsx @@ -1,4 +1,3 @@ -import { TopButton } from '@/components/common'; import Breadcrumb from '@/components/common/Breadcrumb'; import useBreadcrumbPaths from '@/hooks/useBreadcrumbPaths'; import { EssentialPropsWithChildren } from '@/types'; diff --git a/frontend/src/components/layouts/ReviewDisplayLayout/components/ReviewInfoSection/index.tsx b/frontend/src/components/layouts/ReviewDisplayLayout/components/ReviewInfoSection/index.tsx new file mode 100644 index 000000000..d1bf463bf --- /dev/null +++ b/frontend/src/components/layouts/ReviewDisplayLayout/components/ReviewInfoSection/index.tsx @@ -0,0 +1,35 @@ +import { calculateParticle } from '@/utils'; + +import * as S from './styles'; + +export interface ReviewInfoSectionProps { + revieweeName: string; + isReviewList: boolean; + projectName: string; + reviewCount?: number; +} + +const ReviewInfoSection = ({ projectName, revieweeName, reviewCount, isReviewList }: ReviewInfoSectionProps) => { + const revieweeNameWithParticle = calculateParticle({ + target: revieweeName, + particles: { withFinalConsonant: '이', withoutFinalConsonant: '가' }, + }); + + const getReviewInfoMessage = () => { + return isReviewList + ? `${revieweeNameWithParticle} 받은 ${reviewCount}개의 리뷰 목록이에요` + : `${revieweeNameWithParticle} 받은 리뷰를 질문별로 모아봤어요`; + }; + + return ( + + {projectName} + + {revieweeName} + {getReviewInfoMessage()} + + + ); +}; + +export default ReviewInfoSection; diff --git a/frontend/src/pages/ReviewListPage/components/ReviewInfoSection/styles.ts b/frontend/src/components/layouts/ReviewDisplayLayout/components/ReviewInfoSection/styles.ts similarity index 91% rename from frontend/src/pages/ReviewListPage/components/ReviewInfoSection/styles.ts rename to frontend/src/components/layouts/ReviewDisplayLayout/components/ReviewInfoSection/styles.ts index d558c685a..b84fe2ce3 100644 --- a/frontend/src/pages/ReviewListPage/components/ReviewInfoSection/styles.ts +++ b/frontend/src/components/layouts/ReviewDisplayLayout/components/ReviewInfoSection/styles.ts @@ -5,10 +5,11 @@ import media from '@/utils/media'; export const ReviewInfoContainer = styled.div` display: flex; flex-direction: column; - margin: 2rem 0 3rem 1rem; + justify-content: flex-end; + margin: 2rem 0 3rem 0; ${media.small} { - margin-bottom: 1.8rem; + margin-bottom: 1rem; } `; diff --git a/frontend/src/components/layouts/ReviewDisplayLayout/hooks/useReviewDisplayLayoutOptions.ts b/frontend/src/components/layouts/ReviewDisplayLayout/hooks/useReviewDisplayLayoutOptions.ts new file mode 100644 index 000000000..61c3583fd --- /dev/null +++ b/frontend/src/components/layouts/ReviewDisplayLayout/hooks/useReviewDisplayLayoutOptions.ts @@ -0,0 +1,33 @@ +import { useLocation, useNavigate } from 'react-router'; + +import { OptionSwitchOption } from '@/components/common/OptionSwitch'; +import { ROUTE } from '@/constants/route'; +import { useSearchParamAndQuery } from '@/hooks'; + +const useReviewDisplayLayoutOptions = () => { + const { pathname } = useLocation(); + const navigate = useNavigate(); + + const { param: reviewRequestCode } = useSearchParamAndQuery({ + paramKey: 'reviewRequestCode', + }); + + const isReviewCollection = pathname.includes(ROUTE.reviewCollection); + + const reviewDisplayLayoutOptions: OptionSwitchOption[] = [ + { + label: '목록보기', + isChecked: !isReviewCollection, + handleOptionClick: () => navigate(`/${ROUTE.reviewList}/${reviewRequestCode}`), + }, + { + label: '모아보기', + isChecked: isReviewCollection, + handleOptionClick: () => navigate(`/${ROUTE.reviewCollection}/${reviewRequestCode}`), + }, + ]; + + return [...reviewDisplayLayoutOptions]; +}; + +export default useReviewDisplayLayoutOptions; diff --git a/frontend/src/components/layouts/ReviewDisplayLayout/index.tsx b/frontend/src/components/layouts/ReviewDisplayLayout/index.tsx new file mode 100644 index 000000000..8c22cfbfc --- /dev/null +++ b/frontend/src/components/layouts/ReviewDisplayLayout/index.tsx @@ -0,0 +1,34 @@ +import { TopButton, OptionSwitch } from '@/components/common'; +import { EssentialPropsWithChildren } from '@/types'; + +import ReviewInfoSection, { ReviewInfoSectionProps } from './components/ReviewInfoSection'; +import useReviewDisplayLayoutOptions from './hooks/useReviewDisplayLayoutOptions'; +import * as S from './styles'; + +const ReviewDisplayLayout = ({ + revieweeName, + projectName, + reviewCount, + isReviewList, + children, +}: EssentialPropsWithChildren) => { + const reviewDisplayLayoutOptions = useReviewDisplayLayoutOptions(); + + return ( + + + + + + + {children} + + ); +}; + +export default ReviewDisplayLayout; diff --git a/frontend/src/components/layouts/ReviewDisplayLayout/styles.ts b/frontend/src/components/layouts/ReviewDisplayLayout/styles.ts new file mode 100644 index 000000000..2cbf1e1bb --- /dev/null +++ b/frontend/src/components/layouts/ReviewDisplayLayout/styles.ts @@ -0,0 +1,20 @@ +import styled from '@emotion/styled'; + +export const ReviewDisplayLayout = styled.div` + display: flex; + flex-direction: column; + width: 90%; + min-height: inherit; +`; + +export const Container = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + + @media screen and (max-width: 500px) { + flex-direction: column; + align-items: flex-start; + margin-bottom: 2.5rem; + } +`; diff --git a/frontend/src/constants/route.ts b/frontend/src/constants/route.ts index d79e9aed8..0826e9ff8 100644 --- a/frontend/src/constants/route.ts +++ b/frontend/src/constants/route.ts @@ -6,4 +6,5 @@ export const ROUTE = { reviewWritingComplete: 'user/review-writing-complete', detailedReview: 'user/detailed-review', reviewZone: 'user/review-zone', + reviewCollection: 'user/review-collection', }; diff --git a/frontend/src/hooks/useBreadcrumbPaths.ts b/frontend/src/hooks/useBreadcrumbPaths.ts index a36424979..071cbfbf0 100644 --- a/frontend/src/hooks/useBreadcrumbPaths.ts +++ b/frontend/src/hooks/useBreadcrumbPaths.ts @@ -17,25 +17,32 @@ const useBreadcrumbPaths = () => { paramKey: ROUTE_PARAM.reviewId, }); - const breadcrumbPathList: Path[] = [{ pageName: '연결 페이지', path: `${ROUTE.reviewZone}/${reviewRequestCode}` }]; + const breadcrumbPathList: Path[] = [{ pageName: '리뷰 연결', path: `${ROUTE.reviewZone}/${reviewRequestCode}` }]; if (pathname === `/${ROUTE.reviewList}/${reviewRequestCode}`) { - breadcrumbPathList.push({ pageName: '목록 페이지', path: `${ROUTE.reviewList}/${reviewRequestCode}` }); + breadcrumbPathList.push({ pageName: '리뷰 목록', path: `${ROUTE.reviewList}/${reviewRequestCode}` }); + } + + if (pathname === `/${ROUTE.reviewCollection}/${reviewRequestCode}`) { + breadcrumbPathList.push({ pageName: '리뷰 모아보기', path: `${ROUTE.reviewCollection}/${reviewRequestCode}` }); } if (pathname.includes(`/${ROUTE.reviewWriting}/`)) { - breadcrumbPathList.push({ pageName: '작성 페이지', path: pathname }); + breadcrumbPathList.push({ pageName: '리뷰 작성', path: pathname }); } if (pathname.includes(`/${ROUTE.reviewWritingComplete}`)) { - breadcrumbPathList.push({ pageName: '작성 페이지', path: -1 }, { pageName: '작성 완료 페이지', path: pathname }); + breadcrumbPathList.push( + { pageName: '리뷰 작성', path: `${ROUTE.reviewWriting}/${reviewRequestCode}` }, + { pageName: '리뷰 작성 완료 페이지', path: pathname }, + ); } if (pathname.includes(ROUTE.detailedReview)) { breadcrumbPathList.push( - { pageName: '목록 페이지', path: `${ROUTE.reviewList}/${reviewRequestCode}` }, + { pageName: '리뷰 목록', path: `${ROUTE.reviewList}/${reviewRequestCode}` }, { - pageName: '상세 페이지', + pageName: '리뷰 상세', path: `${ROUTE.detailedReview}/${reviewRequestCode}/${reviewId}`, }, ); diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index 9257d8998..da75f7b49 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -24,6 +24,7 @@ const ReviewListPage = lazy(() => import('@/pages/ReviewListPage')); const ReviewWritingCompletePage = lazy(() => import('@/pages/ReviewWritingCompletePage')); const ReviewWritingPage = lazy(() => import('@/pages/ReviewWritingPage')); const ReviewZonePage = lazy(() => import('@/pages/ReviewZonePage')); +const ReviewCollectionPage = lazy(() => import('@/pages/ReviewCollectionPage')); const LoadingPage = lazy(() => import('@/pages/LoadingPage')); @@ -100,6 +101,10 @@ const router = createBrowserRouter([ ), }, + { + path: `${ROUTE.reviewCollection}/:${ROUTE_PARAM.reviewRequestCode}`, + element: , + }, ], }, ]); diff --git a/frontend/src/pages/ReviewCollectionPage/index.tsx b/frontend/src/pages/ReviewCollectionPage/index.tsx new file mode 100644 index 000000000..6e6718d31 --- /dev/null +++ b/frontend/src/pages/ReviewCollectionPage/index.tsx @@ -0,0 +1,25 @@ +import { AuthAndServerErrorFallback, ErrorSuspenseContainer, TopButton } from '@/components'; +import ReviewDisplayLayout from '@/components/layouts/ReviewDisplayLayout'; +import { useGetReviewList } from '@/hooks'; + +const ReviewCollectionPage = () => { + // TODO: 추후 리뷰 그룹 정보를 받아오는 API로 대체 + const { data } = useGetReviewList(); + const { revieweeName, projectName } = data.pages[0]; + + return ( + + +
리뷰 모아보기 페이지 children
+ {Array(50) + .fill('스크롤바 없어서 생기는 layout shift 방지용 + Topbutton 확인용 더미 데이터입니다.') + .map((data, index) => { + return

{data}

; + })} +
+ +
+ ); +}; + +export default ReviewCollectionPage; diff --git a/frontend/src/pages/ReviewCollectionPage/styles.ts b/frontend/src/pages/ReviewCollectionPage/styles.ts new file mode 100644 index 000000000..f404b4253 --- /dev/null +++ b/frontend/src/pages/ReviewCollectionPage/styles.ts @@ -0,0 +1,2 @@ +import styled from '@emotion/styled'; + diff --git a/frontend/src/pages/ReviewListPage/components/PageContents/index.tsx b/frontend/src/pages/ReviewListPage/components/PageContents/index.tsx index c63b68b83..04688cd79 100644 --- a/frontend/src/pages/ReviewListPage/components/PageContents/index.tsx +++ b/frontend/src/pages/ReviewListPage/components/PageContents/index.tsx @@ -1,13 +1,13 @@ import { useNavigate } from 'react-router'; import UndraggableWrapper from '@/components/common/UndraggableWrapper'; +import ReviewDisplayLayout from '@/components/layouts/ReviewDisplayLayout'; import ReviewCard from '@/components/ReviewCard'; import { ROUTE } from '@/constants/route'; import { useGetReviewList, useSearchParamAndQuery } from '@/hooks'; import { useInfiniteScroll } from '../../hooks'; import ReviewEmptySection from '../ReviewEmptySection'; -import ReviewInfoSection from '../ReviewInfoSection'; import * as S from './styles'; @@ -36,8 +36,7 @@ const PageContents = () => { return ( isSuccess && ( - - + {reviews.length === 0 ? ( ) : ( @@ -59,7 +58,7 @@ const PageContents = () => { })} )} - + ) ); }; diff --git a/frontend/src/pages/ReviewListPage/components/PageContents/styles.ts b/frontend/src/pages/ReviewListPage/components/PageContents/styles.ts index 641a8ccaa..08cfe137a 100644 --- a/frontend/src/pages/ReviewListPage/components/PageContents/styles.ts +++ b/frontend/src/pages/ReviewListPage/components/PageContents/styles.ts @@ -1,12 +1,5 @@ import styled from '@emotion/styled'; -export const Layout = styled.div` - display: flex; - flex-direction: column; - width: 90%; - min-height: inherit; -`; - export const ReviewSection = styled.div` display: flex; flex-direction: column; diff --git a/frontend/src/pages/ReviewListPage/components/ReviewInfoSection/index.tsx b/frontend/src/pages/ReviewListPage/components/ReviewInfoSection/index.tsx deleted file mode 100644 index 2b3862075..000000000 --- a/frontend/src/pages/ReviewListPage/components/ReviewInfoSection/index.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { calculateParticle } from '@/utils'; - -import * as S from './styles'; - -interface ReviewInfoSectionProps { - projectName: string; - revieweeName: string; -} - -const ReviewInfoSection = ({ projectName, revieweeName }: ReviewInfoSectionProps) => { - const reviewMessageSuffix = `${calculateParticle({ target: revieweeName, particles: { withFinalConsonant: '이', withoutFinalConsonant: '가' } })} 받은 리뷰 목록이에요`; - - return ( - - {projectName} - - {revieweeName} - {reviewMessageSuffix} - - - ); -}; - -export default ReviewInfoSection; diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index 3c5f182ce..4dc586d19 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -6,3 +6,4 @@ export { default as ReviewListPage } from './ReviewListPage'; export { default as ReviewWritingPage } from './ReviewWritingPage'; export { default as ReviewWritingCompletePage } from './ReviewWritingCompletePage'; export { default as ReviewZonePage } from './ReviewZonePage'; +export { default as ReviewCollectionPage } from './ReviewCollectionPage'; From 84a77f35dd58a82bc0f1571f927b2d42d99ae540 Mon Sep 17 00:00:00 2001 From: Fe <64690761+chysis@users.noreply.github.com> Date: Thu, 10 Oct 2024 15:17:06 +0900 Subject: [PATCH 04/49] =?UTF-8?q?[FE]=20feat:=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EB=AA=A8=EC=95=84=EB=B3=B4=EA=B8=B0=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=EC=9D=98=20=EA=B3=B5=ED=86=B5=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20=ED=8D=BC?= =?UTF-8?q?=EB=B8=94=EB=A6=AC=EC=8B=B1=20(#790)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Switch 컴포넌트 제작 * chore: Switch 컴포넌트 이름 변경 * feat: 리뷰 모아보기에 대한 라우팅 추가 및 임시 페이지 구현 * feat: 리뷰 모아보기와 리뷰 목록 페이지의 공통 레이아웃 제작 * refactor: 공통 레이아웃 제작에 따른 ReviewList 리팩토링 * feat: 리뷰 목록 반응형 적용 * feat: OptionSwitch 반응형 적용 * refactor: ReviewDisplayLayout 반응형 수정 * feat: 공통 Dropdown 컴포넌트 작성 * design: 화살표 버튼 오른쪽 고정 및 옵션을 드래그하지 못하게 수정 * feat: Dropdown 외부 클릭 시 닫히도록 하는 기능 구현 * refactor: Dropdown 로직을 훅으로 분리 * chore: props 명칭 변경 및 선택된 아이템 ellipsis 처리 * feat: Accordion 공통 컴포넌트 작성 * chore: index에 Dropdown, Accordion 추가 * feat: theme에 Dropdown의 z-index 추가 * chore: 누락된 index 추가 * design: Dropdown border 색상 변경 * refactor: Accordion 로직 훅으로 분리 * fix: px을 rem으로 수정 * design: Dropdown 및 Accordion의 margin-bottom 속성 제거 * feat: 초기에 열려있는 Accordion 구현을 위해 prop 추가 * feat: 모아보기 페이지 type 정의 * feat: 모아보기 페이지 목 데이터 작성 * design: Accordion 컴포넌트에서 불필요한 props 제거 * design: Accordion 반응형 구현 * feat: 목 데이터를 사용하여 모아보기 페이지 퍼블리싱 * design: Accordion height 수정 및 스타일 인터페이스 정의 * style: prop명 변경 * design: Dropdown 스타일 인터페이스 정의 * feat: Accordion 제목 왼쪽 정렬 및 애니메이션 추가 * style: 바뀐 prop명 적용 * design: Dropdown이 500px 이하에서 왼쪽 정렬되도록 수정 * merge --------- Co-authored-by: ImxYJL --- frontend/src/assets/downArrow.svg | 3 + .../src/components/common/Accordion/index.tsx | 40 +++++++++++ .../src/components/common/Accordion/styles.ts | 56 +++++++++++++++ .../src/components/common/Dropdown/index.tsx | 41 +++++++++++ .../src/components/common/Dropdown/styles.ts | 71 +++++++++++++++++++ frontend/src/components/common/index.tsx | 3 + frontend/src/hooks/index.ts | 4 ++ frontend/src/hooks/useAccordion.ts | 20 ++++++ frontend/src/hooks/useDropdown.ts | 38 ++++++++++ .../src/mocks/mockData/reviewCollection.ts | 66 +++++++++++++++++ .../src/pages/ReviewCollectionPage/index.tsx | 45 ++++++++++-- .../src/pages/ReviewCollectionPage/styles.ts | 38 ++++++++++ frontend/src/styles/theme.ts | 3 +- frontend/src/types/review.ts | 44 ++++++++++++ 14 files changed, 464 insertions(+), 8 deletions(-) create mode 100644 frontend/src/assets/downArrow.svg create mode 100644 frontend/src/components/common/Accordion/index.tsx create mode 100644 frontend/src/components/common/Accordion/styles.ts create mode 100644 frontend/src/components/common/Dropdown/index.tsx create mode 100644 frontend/src/components/common/Dropdown/styles.ts create mode 100644 frontend/src/hooks/useAccordion.ts create mode 100644 frontend/src/hooks/useDropdown.ts create mode 100644 frontend/src/mocks/mockData/reviewCollection.ts diff --git a/frontend/src/assets/downArrow.svg b/frontend/src/assets/downArrow.svg new file mode 100644 index 000000000..3d437d6c9 --- /dev/null +++ b/frontend/src/assets/downArrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/components/common/Accordion/index.tsx b/frontend/src/components/common/Accordion/index.tsx new file mode 100644 index 000000000..5b546f941 --- /dev/null +++ b/frontend/src/components/common/Accordion/index.tsx @@ -0,0 +1,40 @@ +import { useEffect, useRef, useState } from 'react'; + +import DownArrowIcon from '@/assets/downArrow.svg'; +import useAccordion from '@/hooks/useAccordion'; +import { EssentialPropsWithChildren } from '@/types'; + +import * as S from './styles'; + +interface AccordionProps { + title: string; + isInitiallyOpened?: boolean; +} + +const Accordion = ({ title, isInitiallyOpened = false, children }: EssentialPropsWithChildren) => { + const { isOpened, handleAccordionButtonClick } = useAccordion({ isInitiallyOpened }); + const [contentHeight, setContentHeight] = useState(0); + const contentRef = useRef(null); + + useEffect(() => { + if (contentRef.current) { + setContentHeight(contentRef.current.clientHeight); + } + }, [isOpened]); + + return ( + + + {title} + + + + + {children} + + + + ); +}; + +export default Accordion; diff --git a/frontend/src/components/common/Accordion/styles.ts b/frontend/src/components/common/Accordion/styles.ts new file mode 100644 index 000000000..310a3caba --- /dev/null +++ b/frontend/src/components/common/Accordion/styles.ts @@ -0,0 +1,56 @@ +import styled from '@emotion/styled'; + +interface AccordionStyleProps { + $isOpened: boolean; + $contentHeight?: number; +} + +export const AccordionContainer = styled.div` + display: flex; + flex-direction: column; + gap: ${({ $isOpened }) => ($isOpened ? '2rem' : 0)}; + + width: 100%; + padding: 1rem; + + background-color: ${({ theme, $isOpened }) => ($isOpened ? theme.colors.white : theme.colors.lightGray)}; + border: 0.1rem solid ${({ theme }) => theme.colors.placeholder}; + border-radius: ${({ theme }) => theme.borderRadius.basic}; + + &:hover { + border: 0.1rem solid ${({ theme }) => theme.colors.primaryHover}; + } +`; + +export const AccordionButton = styled.button` + display: flex; + gap: 1rem; + align-items: center; + justify-content: space-between; + + width: 100%; + height: fit-content; + min-height: 3rem; +`; + +export const AccordionTitle = styled.p` + text-align: left; + ::before { + content: 'Q. '; + } +`; + +export const ArrowIcon = styled.img` + transform: ${({ $isOpened }) => ($isOpened ? 'rotate(180deg)' : 'rotate(0deg)')}; + transition: transform 0.3s ease-in-out; +`; + +export const AccordionContentsWrapper = styled.div` + overflow: hidden; +`; + +export const AccordionContents = styled.div` + margin-top: ${({ $isOpened, $contentHeight }) => ($isOpened ? '0' : `-${$contentHeight}px`)}; + opacity: ${({ $isOpened }) => ($isOpened ? '1' : '0')}; + transition: 0.3s ease; +`; diff --git a/frontend/src/components/common/Dropdown/index.tsx b/frontend/src/components/common/Dropdown/index.tsx new file mode 100644 index 000000000..8943573f9 --- /dev/null +++ b/frontend/src/components/common/Dropdown/index.tsx @@ -0,0 +1,41 @@ +import DownArrowIcon from '@/assets/downArrow.svg'; +import useDropdown from '@/hooks/useDropdown'; + +import * as S from './styles'; + +interface DropdownItem { + text: string; + value: string; +} + +interface DropdownProps { + items: DropdownItem[]; + selectedItem: string; + handleSelect: (item: string) => void; +} + +const Dropdown = ({ items, selectedItem: selectedOption, handleSelect }: DropdownProps) => { + const { isOpened, handleDropdownButtonClick, handleOptionClick, dropdownRef } = useDropdown({ handleSelect }); + + return ( + + + {selectedOption} + + + {isOpened && ( + + {items.map((item) => { + return ( + handleOptionClick(item.value)}> + {item.text} + + ); + })} + + )} + + ); +}; + +export default Dropdown; diff --git a/frontend/src/components/common/Dropdown/styles.ts b/frontend/src/components/common/Dropdown/styles.ts new file mode 100644 index 000000000..74ed7fcd3 --- /dev/null +++ b/frontend/src/components/common/Dropdown/styles.ts @@ -0,0 +1,71 @@ +import styled from '@emotion/styled'; + +interface DropdownStyleProps { + $isOpened: boolean; +} + +export const DropdownContainer = styled.div` + position: relative; + display: flex; + flex-direction: column; + width: 24rem; +`; + +export const DropdownButton = styled.button` + display: flex; + gap: 1rem; + justify-content: space-between; + + width: 100%; + padding: 1rem; + + background-color: ${({ theme }) => theme.colors.white}; + border: 0.1rem solid ${({ theme }) => theme.colors.placeholder}; + border-radius: ${({ theme }) => theme.borderRadius.basic}; + + &:hover { + background-color: ${({ theme }) => theme.colors.lightGray}; + } +`; + +export const SelectedOption = styled.p` + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +export const ArrowIcon = styled.img` + transform: ${({ $isOpened }) => ($isOpened ? 'rotate(180deg)' : 'rotate(0deg)')}; + transition: transform 0.3s ease-in-out; +`; + +export const ItemContainer = styled.ul` + position: absolute; + z-index: ${({ theme }) => theme.zIndex.dropdown}; + top: 100%; + + overflow: hidden; + + width: 100%; + + border: 0.1rem solid ${({ theme }) => theme.colors.placeholder}; + border-radius: ${({ theme }) => theme.borderRadius.basic}; +`; + +export const DropdownItem = styled.li` + cursor: pointer; + user-select: none; + + display: flex; + align-items: center; + + width: 100%; + height: 4rem; + padding: 0 1rem; + + background-color: ${({ theme }) => theme.colors.white}; + + &:hover { + background-color: ${({ theme }) => theme.colors.lightGray}; + } +`; diff --git a/frontend/src/components/common/index.tsx b/frontend/src/components/common/index.tsx index 48218fd2f..5c2cdfdbe 100644 --- a/frontend/src/components/common/index.tsx +++ b/frontend/src/components/common/index.tsx @@ -7,5 +7,8 @@ export { default as Checkbox } from './Checkbox'; export { default as CheckboxItem } from './CheckboxItem'; export { default as EyeButton } from './EyeButton'; export { default as Carousel } from './Carousel'; +export { default as Accordion } from './Accordion'; +export { default as Dropdown } from './Dropdown'; + export { default as OptionSwitch } from './OptionSwitch'; export * from './modals'; diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index 4c7dbbbfa..5532776bd 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -2,6 +2,10 @@ export { default as useSidebar } from './useSidebar'; export { default as useSearchParamAndQuery } from './useSearchParamAndQuery'; export { default as useEyeButton } from './useEyeButton'; export { default as usePasswordValidation } from './usePasswordValidation'; +export { default as useDropdown } from './useDropdown'; +export { default as useAccordion } from './useAccordion'; +export { default as useBreadcrumbPaths } from './useBreadcrumbPaths'; +export { default as useTopButton } from './useTopButton'; export * from './review'; export * from './reviewGroup'; export * from './modal'; diff --git a/frontend/src/hooks/useAccordion.ts b/frontend/src/hooks/useAccordion.ts new file mode 100644 index 000000000..a9055bbbc --- /dev/null +++ b/frontend/src/hooks/useAccordion.ts @@ -0,0 +1,20 @@ +import { useState } from 'react'; + +interface UseAccordionProps { + isInitiallyOpened: boolean; +} + +const useAccordion = ({ isInitiallyOpened }: UseAccordionProps) => { + const [isOpened, setIsOpened] = useState(isInitiallyOpened); + + const handleAccordionButtonClick = () => { + setIsOpened((prev) => !prev); + }; + + return { + isOpened, + handleAccordionButtonClick, + }; +}; + +export default useAccordion; diff --git a/frontend/src/hooks/useDropdown.ts b/frontend/src/hooks/useDropdown.ts new file mode 100644 index 000000000..da17270ca --- /dev/null +++ b/frontend/src/hooks/useDropdown.ts @@ -0,0 +1,38 @@ +import { useEffect, useRef, useState } from 'react'; + +interface UseDropdownProps { + handleSelect: (option: string) => void; +} + +const useDropdown = ({ handleSelect }: UseDropdownProps) => { + const [isOpened, setIsOpened] = useState(false); + + const dropdownRef = useRef(null); + + const handleDropdownButtonClick = () => { + setIsOpened((prev) => !prev); + }; + + const handleOptionClick = (option: string) => { + handleSelect(option); + setIsOpened(false); + }; + + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpened(false); + } + }; + + useEffect(() => { + document.addEventListener('mousedown', handleClickOutside); + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [dropdownRef]); + + return { isOpened, handleDropdownButtonClick, handleOptionClick, dropdownRef }; +}; + +export default useDropdown; diff --git a/frontend/src/mocks/mockData/reviewCollection.ts b/frontend/src/mocks/mockData/reviewCollection.ts new file mode 100644 index 000000000..a2db044e2 --- /dev/null +++ b/frontend/src/mocks/mockData/reviewCollection.ts @@ -0,0 +1,66 @@ +import { GroupedReviews, GroupedSection, ReviewSummary } from '@/types'; + +export const REVIEW_SUMMARY_MOCK_DATA: ReviewSummary = { + projectName: '리뷰미', + revieweeName: '에프이', + reviewCount: 5, +}; + +export const GROUPED_SECTION_MOCK_DATA: GroupedSection = { + sections: [ + { id: 0, name: '강점 카테고리' }, + { id: 1, name: '커뮤니케이션, 협업 능력' }, + { id: 2, name: '문제 해결 능력' }, + { id: 3, name: '시간 관리 능력' }, + { id: 4, name: '기술 역량, 전문 지식' }, + { id: 5, name: '성장 마인드셋' }, + { id: 6, name: '단점 피드백' }, + { id: 7, name: '추가 리뷰 및 응원' }, + ], +}; + +export const GROUPED_REVIEWS_MOCK_DATA: GroupedReviews = { + reviews: [ + { + question: { + name: '커뮤니케이션, 협업 능력에서 어떤 부분이 인상 깊었는지 선택해주세요', + type: 'CHECKBOX', + }, + answers: null, + votes: [ + { content: '반대 의견을 내더라도 듣는 사람이 기분 나쁘지 않게 이야기해요', count: 5 }, + { content: '팀원들의 의견을 잘 모아서 회의가 매끄럽게 진행되도록 해요', count: 4 }, + { content: '팀의 분위기를 주도해요', count: 3 }, + { content: '주장을 이야기할 때에는 합당한 근거가 뒤따라요', count: 2 }, + { content: '팀에게 필요한 것과 그렇지 않은 것을 잘 구분해요', count: 2 }, + { content: '팀 내 주어진 요구사항에 우선순위를 잘 매겨요 (커뮤니케이션 능력을 특화하자)', count: 1 }, + { content: '서로 다른 분야간의 소통도 중요하게 생각해요', count: 1 }, + ], + }, + { + question: { + name: '위에서 선택한 사항에 대해 조금 더 자세히 설명해주세요', + type: 'TEXT', + }, + answers: [ + { + content: + '장의 시작부분은 짧고 직접적이며, 뒤따라 나올 복잡한 정보를 어떻게 해석해야 할 것인지 프레임을 짜주는 역할을 해야 한다. 그러면 아무리 긴 문장이라도 쉽게 읽힌다.', + }, + { + content: + '고액공제건강보험과 건강저축계좌를 만들어 노동자와 고용주가 세금공제를 받을 수 있도록 하면 결과적으로 노동자의 의료보험 부담이 커진다.', + }, + { + content: + '장의 시작부분은 짧고 직접적이며, 뒤따라 나올 복잡한 정보를 어떻게 해석해야 할 것인지 프레임을 짜주는 역할을 해야 한다. 그러면 아무리 긴 문장이라도 쉽게 읽힌다.', + }, + { + content: + '고액공제건강보험과 건강저축계좌를 만들어 노동자와 고용주가 세금공제를 받을 수 있도록 하면 결과적으로 노동자의 의료보험 부담이 커진다.', + }, + ], + votes: null, + }, + ], +}; diff --git a/frontend/src/pages/ReviewCollectionPage/index.tsx b/frontend/src/pages/ReviewCollectionPage/index.tsx index 6e6718d31..b5ee31492 100644 --- a/frontend/src/pages/ReviewCollectionPage/index.tsx +++ b/frontend/src/pages/ReviewCollectionPage/index.tsx @@ -1,21 +1,52 @@ -import { AuthAndServerErrorFallback, ErrorSuspenseContainer, TopButton } from '@/components'; +import { useState } from 'react'; + +import { Accordion, AuthAndServerErrorFallback, Dropdown, ErrorSuspenseContainer, TopButton } from '@/components'; import ReviewDisplayLayout from '@/components/layouts/ReviewDisplayLayout'; import { useGetReviewList } from '@/hooks'; +import { GROUPED_REVIEWS_MOCK_DATA, GROUPED_SECTION_MOCK_DATA } from '@/mocks/mockData/reviewCollection'; + +import * as S from './styles'; const ReviewCollectionPage = () => { // TODO: 추후 리뷰 그룹 정보를 받아오는 API로 대체 const { data } = useGetReviewList(); const { revieweeName, projectName } = data.pages[0]; + // TODO: react-query 적용 및 드롭다운 아이템 선택 시 요청 + const reviewSectionList = GROUPED_SECTION_MOCK_DATA.sections.map((section) => { + return { text: section.name, value: section.name }; + }); + const [reviewSection, setReviewSection] = useState(reviewSectionList[0].value); + return ( -
리뷰 모아보기 페이지 children
- {Array(50) - .fill('스크롤바 없어서 생기는 layout shift 방지용 + Topbutton 확인용 더미 데이터입니다.') - .map((data, index) => { - return

{data}

; - })} + + + setReviewSection(item)} + /> + + + {GROUPED_REVIEWS_MOCK_DATA.reviews.map((review, index) => { + return ( + + {review.question.type === 'CHECKBOX' ? ( +

객관식 통계 차트

+ ) : ( + + {review.answers?.map((answer, index) => { + return {answer.content}; + })} + + )} +
+ ); + })} +
+
diff --git a/frontend/src/pages/ReviewCollectionPage/styles.ts b/frontend/src/pages/ReviewCollectionPage/styles.ts index f404b4253..94566440e 100644 --- a/frontend/src/pages/ReviewCollectionPage/styles.ts +++ b/frontend/src/pages/ReviewCollectionPage/styles.ts @@ -1,2 +1,40 @@ import styled from '@emotion/styled'; +export const ReviewCollectionContainer = styled.div` + display: flex; + flex-direction: column; + gap: 5rem; + + width: 100%; + padding: 1rem; + + border: 0.1rem solid ${({ theme }) => theme.colors.lightGray}; + border-radius: ${({ theme }) => theme.borderRadius.basic}; +`; + +export const ReviewSectionDropdown = styled.div` + display: flex; + justify-content: flex-end; + + @media screen and (max-width: 500px) { + justify-content: flex-start; + } +`; + +export const ReviewCollection = styled.div` + display: flex; + flex-direction: column; + gap: 1rem; +`; + +export const ReviewAnswerContainer = styled.ul` + display: flex; + flex-direction: column; + gap: 1rem; + list-style-position: inside; +`; + +export const ReviewAnswer = styled.li` + margin-left: 2.2rem; + text-indent: -2.2rem; +`; diff --git a/frontend/src/styles/theme.ts b/frontend/src/styles/theme.ts index 7720e734c..da7978c59 100644 --- a/frontend/src/styles/theme.ts +++ b/frontend/src/styles/theme.ts @@ -75,8 +75,9 @@ export const colors: ThemeProperty = { }; export const zIndex: ThemeProperty = { - modal: 999, main: 1, + dropdown: 998, + modal: 999, }; export const breakpoints = { diff --git a/frontend/src/types/review.ts b/frontend/src/types/review.ts index bde8b9e95..858a70d8f 100644 --- a/frontend/src/types/review.ts +++ b/frontend/src/types/review.ts @@ -90,6 +90,50 @@ export interface Category { content: string; } +// 목록보기, 모아보기에서 공통적으로 사용되는 정보 +export interface ReviewSummary { + projectName: string; + revieweeName: string; + reviewCount: number; +} + +export interface GroupedSection { + sections: { + id: number; + name: string; + }[]; +} + +export interface GroupedReviews { + reviews: GroupedReview[]; +} + +export interface GroupedReview { + question: { + name: string; + type: QuestionType; + }; + /** + * CollectedReviewAnswer[] : 주관식 질문에서 답변 모아놓은 배열 + * null : 객관식 질문인 경우 + */ + answers: ReviewAnswer[] | null; + /** + * CollectedReviewVotes[] : 객관식 질문에서 옵션-득표수 모아놓은 배열 + * null : 주관식 질문인 경우 + */ + votes: ReviewVotes[] | null; +} + +export interface ReviewAnswer { + content: string; +} + +export interface ReviewVotes { + content: string; + count: number; +} + // 리뷰 작성 카드 관련 타입들 export interface ReviewWritingFormData { formId: number; From be289b035042755b156c934d93dbd73d3a3bc70f Mon Sep 17 00:00:00 2001 From: sooyeon Date: Thu, 10 Oct 2024 15:39:28 +0900 Subject: [PATCH 05/49] =?UTF-8?q?[FE]=20feat:=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EB=AA=A8=EC=95=84=EB=B3=B4=EA=B8=B0=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=A7=88=EB=AC=B8=EB=B3=84=20=ED=86=B5=EA=B3=84=20=EC=B0=A8?= =?UTF-8?q?=ED=8A=B8=20=EA=B5=AC=ED=98=84=20(#803)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Switch 컴포넌트 제작 * chore: Switch 컴포넌트 이름 변경 * feat: 리뷰 모아보기에 대한 라우팅 추가 및 임시 페이지 구현 * feat: 리뷰 모아보기와 리뷰 목록 페이지의 공통 레이아웃 제작 * refactor: 공통 레이아웃 제작에 따른 ReviewList 리팩토링 * feat: 리뷰 목록 반응형 적용 * feat: OptionSwitch 반응형 적용 * refactor: ReviewDisplayLayout 반응형 수정 * feat: 공통 Dropdown 컴포넌트 작성 * design: 화살표 버튼 오른쪽 고정 및 옵션을 드래그하지 못하게 수정 * feat: Dropdown 외부 클릭 시 닫히도록 하는 기능 구현 * refactor: Dropdown 로직을 훅으로 분리 * chore: props 명칭 변경 및 선택된 아이템 ellipsis 처리 * feat: Accordion 공통 컴포넌트 작성 * chore: index에 Dropdown, Accordion 추가 * feat: theme에 Dropdown의 z-index 추가 * chore: 누락된 index 추가 * design: Dropdown border 색상 변경 * refactor: Accordion 로직 훅으로 분리 * fix: px을 rem으로 수정 * design: Dropdown 및 Accordion의 margin-bottom 속성 제거 * feat: 초기에 열려있는 Accordion 구현을 위해 prop 추가 * feat: 모아보기 페이지 type 정의 * feat: 모아보기 페이지 목 데이터 작성 * design: Accordion 컴포넌트에서 불필요한 props 제거 * design: Accordion 반응형 구현 * feat: 목 데이터를 사용하여 모아보기 페이지 퍼블리싱 * feat: 질문별 통계 차트 구현 * feat: 통계 차트 세부사항 표시 * feat: 질문 길이에 따라 색상을 생성하는 함수 구현 * chore: DoughnutChart 파일 위치 변경 * feat: 차트 애니메이션 적용 및 비율을 텍스트로 시각화 * chore: 비율을 'n표' 형식으로 수정 * chore: 불필요한 텍스트 제거 * feat: ReviewCollectionPage에 DoughnutChart 컴포넌트 적용 * feat: 통계 차트에 반응형 적용 * refactor: theme에 있는 primary 색상 활용 * chore: 불필요한 코드 제거 * refactor: 차트 애니메이션 제거 * chore: Accordion에 넘겨주는 props 변수명 수정 --------- Co-authored-by: ImxYJL Co-authored-by: chysis --- .../src/mocks/mockData/reviewCollection.ts | 2 +- .../components/DoughnutChart/index.tsx | 84 +++++++++++++++++++ .../components/DoughnutChart/styles.ts | 15 ++++ .../components/DoughnutChartDetails/index.tsx | 26 ++++++ .../components/DoughnutChartDetails/styles.ts | 58 +++++++++++++ .../src/pages/ReviewCollectionPage/index.tsx | 3 +- .../utils/generateGradientColors.ts | 33 ++++++++ 7 files changed, 219 insertions(+), 2 deletions(-) create mode 100644 frontend/src/pages/ReviewCollectionPage/components/DoughnutChart/index.tsx create mode 100644 frontend/src/pages/ReviewCollectionPage/components/DoughnutChart/styles.ts create mode 100644 frontend/src/pages/ReviewCollectionPage/components/DoughnutChartDetails/index.tsx create mode 100644 frontend/src/pages/ReviewCollectionPage/components/DoughnutChartDetails/styles.ts create mode 100644 frontend/src/pages/ReviewCollectionPage/utils/generateGradientColors.ts diff --git a/frontend/src/mocks/mockData/reviewCollection.ts b/frontend/src/mocks/mockData/reviewCollection.ts index a2db044e2..94aa20786 100644 --- a/frontend/src/mocks/mockData/reviewCollection.ts +++ b/frontend/src/mocks/mockData/reviewCollection.ts @@ -33,7 +33,7 @@ export const GROUPED_REVIEWS_MOCK_DATA: GroupedReviews = { { content: '팀의 분위기를 주도해요', count: 3 }, { content: '주장을 이야기할 때에는 합당한 근거가 뒤따라요', count: 2 }, { content: '팀에게 필요한 것과 그렇지 않은 것을 잘 구분해요', count: 2 }, - { content: '팀 내 주어진 요구사항에 우선순위를 잘 매겨요 (커뮤니케이션 능력을 특화하자)', count: 1 }, + { content: '팀 내 주어진 요구사항에 우선순위를 잘 매겨요', count: 1 }, { content: '서로 다른 분야간의 소통도 중요하게 생각해요', count: 1 }, ], }, diff --git a/frontend/src/pages/ReviewCollectionPage/components/DoughnutChart/index.tsx b/frontend/src/pages/ReviewCollectionPage/components/DoughnutChart/index.tsx new file mode 100644 index 000000000..7464323cf --- /dev/null +++ b/frontend/src/pages/ReviewCollectionPage/components/DoughnutChart/index.tsx @@ -0,0 +1,84 @@ +import theme from '@/styles/theme'; +import { ReviewVotes } from '@/types'; + +import generateGradientColors from '../../utils/generateGradientColors'; +import DoughnutChartDetails from '../DoughnutChartDetails'; + +import * as S from './styles'; + +const DOUGHNUT_COLOR = { + START: `${theme.colors.primary}`, + END: '#e7e3f9', +}; + +const DoughnutChart = ({ reviewVotes }: { reviewVotes: ReviewVotes[] }) => { + const radius = 90; // 차트의 반지름 + const circumference = 2 * Math.PI * radius; // 차트의 둘레 + const centerX = 125; // svg의 중앙 좌표 (x) + const centerY = 125; // svg의 중앙 좌표 (y) + + const total = reviewVotes.reduce((acc, reviewVote) => acc + reviewVote.count, 0); + const ratios = reviewVotes.map((reviewVote) => reviewVote.count / total); + + // 누적 값 계산 + const acc = reviewVotes.reduce( + (arr, reviewVote) => { + const last = arr[arr.length - 1]; + return [...arr, last + reviewVote.count]; // 현재 값과 이전 누적 값을 더해 새로운 배열 반환 + }, + [0], + ); + + // 색상 시작 및 끝값 정의 + const colors = generateGradientColors(reviewVotes.length, DOUGHNUT_COLOR.START, DOUGHNUT_COLOR.END); + + // 각 조각의 중심 좌표를 계산하는 함수 + const calculateLabelPosition = (startAngle: number, endAngle: number) => { + const midAngle = (startAngle + endAngle) / 2; // 중간 각도 + const labelRadius = radius * 1; // 텍스트가 배치될 반지름 (차트 내부) + const x = centerX + labelRadius * Math.cos((midAngle * Math.PI) / 180); + const y = centerY + labelRadius * Math.sin((midAngle * Math.PI) / 180); + return { x, y }; + }; + + return ( + + + {reviewVotes.map((reviewVote, index) => { + const ratio = reviewVote.count / total; + const fillSpace = circumference * ratio; + const emptySpace = circumference - fillSpace; + const offset = (acc[index] / total) * circumference; + + // 시작 각도와 끝 각도를 계산 + const startAngle = (acc[index] / total) * 360 + 90; + const endAngle = ((acc[index] + reviewVote.count) / total) * 360 - 90; + + // 비율 레이블의 위치를 계산 + const { x, y } = calculateLabelPosition(startAngle, endAngle); + + return ( + + + + {Math.floor(ratios[index] * 100)}% + + + ); + })} + + + + ); +}; + +export default DoughnutChart; diff --git a/frontend/src/pages/ReviewCollectionPage/components/DoughnutChart/styles.ts b/frontend/src/pages/ReviewCollectionPage/components/DoughnutChart/styles.ts new file mode 100644 index 000000000..af15be420 --- /dev/null +++ b/frontend/src/pages/ReviewCollectionPage/components/DoughnutChart/styles.ts @@ -0,0 +1,15 @@ +import styled from '@emotion/styled'; + +import media from '@/utils/media'; + +export const DoughnutChartContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; + + gap: 5rem; + + ${media.small} { + flex-direction: column; + } +`; diff --git a/frontend/src/pages/ReviewCollectionPage/components/DoughnutChartDetails/index.tsx b/frontend/src/pages/ReviewCollectionPage/components/DoughnutChartDetails/index.tsx new file mode 100644 index 000000000..93a2e7883 --- /dev/null +++ b/frontend/src/pages/ReviewCollectionPage/components/DoughnutChartDetails/index.tsx @@ -0,0 +1,26 @@ +import { ReviewVotes } from '@/types'; + +import * as S from './styles'; + +interface DoughnutChartDetails { + reviewVotes: ReviewVotes[]; + colors: string[]; +} + +const DoughnutChartDetails = ({ reviewVotes, colors }: DoughnutChartDetails) => { + return ( + + {reviewVotes.map((reviewVote, index) => ( + + + + {reviewVote.content} + + {reviewVote.count}표 + + ))} + + ); +}; + +export default DoughnutChartDetails; diff --git a/frontend/src/pages/ReviewCollectionPage/components/DoughnutChartDetails/styles.ts b/frontend/src/pages/ReviewCollectionPage/components/DoughnutChartDetails/styles.ts new file mode 100644 index 000000000..899ae23b6 --- /dev/null +++ b/frontend/src/pages/ReviewCollectionPage/components/DoughnutChartDetails/styles.ts @@ -0,0 +1,58 @@ +import styled from '@emotion/styled'; + +import media from '@/utils/media'; + +export const DoughnutChartDetailList = styled.div` + display: flex; + flex-direction: column; + + gap: 2rem; + + margin: 2rem; + + ${media.small} { + margin: 0 1rem; + } +`; + +export const DetailItem = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + + gap: 1rem; +`; + +export const ContentContainer = styled.div` + display: flex; + align-items: center; + + gap: 1rem; +`; + +export const ChartColor = styled.div<{ color: string }>` + background-color: ${({ color }) => color}; + + width: 2rem; + height: 2rem; + + border-radius: 0.5rem; + flex-shrink: 0; + + ${media.small} { + width: 1.6rem; + height: 1.6rem; + } +`; + +export const Description = styled.span` + ${media.small} { + font-size: ${({ theme }) => theme.fontSize.small}; + } +`; + +export const ReviewVoteResult = styled.span` + ${media.small} { + font-size: ${({ theme }) => theme.fontSize.small}; + } +`; diff --git a/frontend/src/pages/ReviewCollectionPage/index.tsx b/frontend/src/pages/ReviewCollectionPage/index.tsx index b5ee31492..3bd098ace 100644 --- a/frontend/src/pages/ReviewCollectionPage/index.tsx +++ b/frontend/src/pages/ReviewCollectionPage/index.tsx @@ -5,6 +5,7 @@ import ReviewDisplayLayout from '@/components/layouts/ReviewDisplayLayout'; import { useGetReviewList } from '@/hooks'; import { GROUPED_REVIEWS_MOCK_DATA, GROUPED_SECTION_MOCK_DATA } from '@/mocks/mockData/reviewCollection'; +import DoughnutChart from './components/DoughnutChart'; import * as S from './styles'; const ReviewCollectionPage = () => { @@ -34,7 +35,7 @@ const ReviewCollectionPage = () => { return ( {review.question.type === 'CHECKBOX' ? ( -

객관식 통계 차트

+ ) : ( {review.answers?.map((answer, index) => { diff --git a/frontend/src/pages/ReviewCollectionPage/utils/generateGradientColors.ts b/frontend/src/pages/ReviewCollectionPage/utils/generateGradientColors.ts new file mode 100644 index 000000000..87059804b --- /dev/null +++ b/frontend/src/pages/ReviewCollectionPage/utils/generateGradientColors.ts @@ -0,0 +1,33 @@ +// Hex 색상을 RGB로 변환하는 함수 +const hexToRGB = (hex: string) => { + const bigint = parseInt(hex.slice(1), 16); + return [bigint >> 16, (bigint >> 8) & 255, bigint & 255]; +}; + +// RGB 색상을 Hex로 변환하는 함수 +const rgbToHex = (r: number, g: number, b: number) => { + return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase()}`; +}; + +// 두 색상 사이의 색상을 계산하는 함수 +const interpolateColor = (start: number[], end: number[], factor: number) => { + const result = start.map((startValue, index) => Math.round(startValue + factor * (end[index] - startValue))); + return result; +}; + +// reviewVotes 길이에 따라 색상 배열을 생성하는 함수 +const generateGradientColors = (length: number, startHex: string, endHex: string) => { + const startColor = hexToRGB(startHex); + const endColor = hexToRGB(endHex); + const colors = []; + + for (let i = 0; i < length; i++) { + const factor = i / (length - 1); + const color = interpolateColor(startColor, endColor, factor); + colors.push(rgbToHex(color[0], color[1], color[2])); + } + + return colors; +}; + +export default generateGradientColors; From fc4a82259082867a143c169ced737eb5a1b4e1cd Mon Sep 17 00:00:00 2001 From: badahertz52 Date: Thu, 10 Oct 2024 16:51:58 +0900 Subject: [PATCH 06/49] =?UTF-8?q?[FE]=20feat:=20=ED=95=98=EC=9D=B4?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=ED=8C=85=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#783)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci : draft-js , react-draft-wysiwyg 패키지 설치 * feat: 에디터 테스트 페이지 추가 * feat: 형광펜 기능 및 변경된 내용을 추출해 다시 Editor로 보여주는 기능 추가 * feat: 서버에 필요한 스타일 정보만 주는 방향으로 기능 수정 * feat: 하이라이트 기능 - 블럭별로, 하나의 하이라이트 기능 생성 * refactor: 하이라이트 기능 - 하나의 블록에 하나의 하이라이트 리팩토링 * feat: 하나의 블럭에 여러개의 하이라이트 추가 기능 구현 * refactor: 상태명 변경, 함수에 대한 설명 추가 blocks->blockList * feat: 하이라이트를 일부만 선택해도 지우는 기능 추가 * fix: 여러 블럭 하이라이트 추가 오류 수정 * fix: 여러 블록 하이라이트 삭제 기능 오류 수정 * refactor: highlight 타입, 유틸 함수 분리 * refactor: HighlightEditor 공통 컴포넌트로 분리 * feat: 하이라이트 추가 및 삭제 후 document에 선택된 것들 삭제하는 기능 추가 * ci: draft-js 관련 패키지 모두 삭제 * refactor: useHighlight훅 분리 하이라이트에 따른 blockList 상태, 버튼 이벤트 핸들러들을 useHighlight훅으로 분리 * feat: EidtorTestPage에 drft-js 사용한 Editor 삭제 및 HighlightEditor 반영 * refactor: 타입명 변경( EditorBlock -> EditorBlockData) * refactor: EDIOR_BLOCK_CLASS_NAME 상수 추가 및 적용 * refactor:HightlightEditor에서 EditorBlock , Sentence 컴포넌트 분리 * refactor: getSelectionInfo 분리 -calculateStartAndEndBlock , getSelectionOffsetBlockInfo 분리 * feat: 드래그 방향이 정방향인지 역방향인지 계산하는 유틸 함수 추가 * fix: isForwardDrag 계산 오류 수정 및 타입명 변경 * feat: 드래그 방향에 다른 버튼 위치 계산해서 화면에 띄우는 기능 추가 * feat: 하이라이트 적용 후, 버튼 숨기는 기능 추가 * refactor: useHightlightButtonPosition 훅 분리 및 HIGHLIGHT_BUTTON_CLASS_NAME 위치 변경 * feat: 하이라이트 편집 기능 켜기/끄기 기능 추가 * feat: 하이라이트 적용 여부를 판단하는 훅 생성 및 하이라이트 적용된 span 클래스 추가 * feat: Highlight에 length 대신 end를 받는 것으로 변경 * fix: 하이라이트 추가/삭제 판단 기능 오류 수정 - 오류 :anchorNode, focusNode가 하이라이트 되어 있고, 그 사이가 하이라이트 안되어있을 때, delete 버튼이 나오는 오류 수정 * fix: 형광펜 추가,삭제시 start,end 관련 오류 수정 * refactor: splitTextWidthHighlightList 위치 변경(uitls-> EditorBlock) * fix: 삭제 시, 마지막 블럭 오류 수정 * fix: end의 경우 포커스 위치가 글자 idnex보다 1커서 생기는 오류 수정 * refactor: highlight 유틸 함수들 파일별로 분리 * refactor: 하이라이트 이진법 배열 생성 함수 분리 및 함수 설명문 추가 * feat: 하이라이트 된 span 클릭 시, 하이라이트 삭제 할 수 있는 기능 생성 * refactor : HighlightRemoverWrapper,HighlightToggleButtonContainer 분리 및 네이밍 변경 * feat: 하나의 질문에 대한 여러 답변들에 하이라이트 기능을 넣을 수 있도록 수정 * fix: 여러 답변을 걸쳐 하이라이트를 추가할 때 오류 수정 - answer에 해당 답변 index 추가 * feat: 여러 답변에 대한 하이라이트 삭제 기능 추가 * refactor: Sentence -> Syntax로 네이밍 변경, HIghlight 키 네이밍 변경 * feat: 서버에서 내려주는 답변 데이터를 사용할 수 있도록 함 * refactor: useHighlight 리팩토링 및 api 연결 준비 * design: 스크린 리더기에서만 읽히는 클래스 추가 및 스타일 설정 * feat: HighlightButton 합성 컴포넌트 생성 및 스타일 적용 * fix: fixed 설정으로 인한 스크롤 시 버튼 위치 이동하는 오류 수정 - absoulte를 사용해 부모 위치를 기준으로 함 * chore: 불필요한 코드 삭제 * design: 버튼 그림자 변경 * design: 하이라이트 버튼 그림자 더 진하게 변경 * fix: IterableIterator 순환 오류 해결 * chore: 버튼 설명 수정 * feat: SwitchButton 생성 * refactor: isAbleEdit -> isEditAble 네이밍 변경 * feat: HighlightEditor 컴포넌트, 스타일 파일 생성 * design: SwitchButton에 마진 추가 * chore: pareInt -> Number로 변경 * refactor: div 태그 p 태그로 변경 * feat: 하이라이트 mouseup, mouedown을 document에 적용 * fix: 한 글자 삭제 시 삭제 안되는 오류 수정 * fix: 하이라이트 된 한 글자에 대한 드래그 시 버튼 오류 수정 - 드래그시 드래그용 삭제 버튼과 클릭용 삭제 버튼 모두 나오는 오류 수정 * refactor: 서버에서 주는 리뷰 모아보기 데이터에 맞춰서 타입 리팩토링 * feat: 하이라이트 api 핸들러 생성 및 endpoints 생성 * feat: 하이라이트 목 핸들러 생성 * feat: HighlightEditor에 questinoId 추가 * feat: useHighlight에 api 연동 - 드래그를 통한 추가/삭제 및 클릭을 통한 삭제 핸들러에 api 연동 * chore: 코드 설명을 위한 주석 추가 * fix: 토글 버튼이 Editor를 넘어설 경우, 전체적으로 보이지 않는 오류 수정 * fix : 클릭 삭제 버튼이 Editor 위치를 넘어서는 오류 수정 * refactor: 하이라이트 버튼 사이즈 상수화 * refactor: block ->line으로 수정 * fix: 여러 글자 중 하나에 대한 하이라이트 삭제 오류 수정 - 여러 글자에 하이라이트 되어있고, 그 중 한 글자만 삭제될 경우 전체 하이라이트가 삭제되는 오류 수정 * refactor: editable 변수명 오타 수정 * chore: 불필요한 import문 삭제 --- frontend/src/apis/collection.ts | 38 ++ frontend/src/apis/endpoints.ts | 1 + frontend/src/assets/eraser.svg | 9 + frontend/src/assets/highlighter.svg | 9 + frontend/src/assets/trash.svg | 9 + .../highlight/EditSwitchButton/index.tsx | 18 + .../highlight/EditSwitchButton/style.ts | 28 ++ .../highlight/EditorLineBlock/index.tsx | 69 +++ .../highlight/HighlightButton/index.tsx | 78 +++ .../highlight/HighlightButton/style.ts | 36 ++ .../highlight/HighlightEditor/index.tsx | 127 +++++ .../highlight/HighlightEditor/style.ts | 8 + .../HighlightRemoverWrapper/index.tsx | 16 + .../HighlightToggleButtonContainer/index.tsx | 31 ++ .../src/components/highlight/Syntax/index.tsx | 34 ++ .../src/components/highlight/Syntax/style.ts | 10 + frontend/src/constants/highlight.ts | 15 + frontend/src/constants/index.ts | 2 + frontend/src/constants/screenReader.ts | 1 + frontend/src/hooks/highlight/index.tsx | 4 + .../src/hooks/highlight/useCheckHighlight.tsx | 31 ++ frontend/src/hooks/highlight/useHighlight.tsx | 461 ++++++++++++++++++ .../highlight/useHighlightRemoverPosition.tsx | 45 ++ .../useHighlightToggleButtonPosition.tsx | 75 +++ frontend/src/hooks/index.ts | 1 + frontend/src/index.tsx | 10 +- frontend/src/mocks/handlers/collection.ts | 11 + frontend/src/mocks/handlers/index.ts | 3 +- frontend/src/pages/EditorTestPage/index.tsx | 27 + frontend/src/styles/globalStyles.ts | 6 + frontend/src/types/highlight.ts | 64 +++ frontend/src/types/index.ts | 2 +- frontend/src/utils/highlight/highlighList.ts | 145 ++++++ frontend/src/utils/highlight/index.ts | 2 + frontend/src/utils/highlight/selection.ts | 243 +++++++++ frontend/src/utils/index.ts | 1 + 36 files changed, 1662 insertions(+), 8 deletions(-) create mode 100644 frontend/src/apis/collection.ts create mode 100644 frontend/src/assets/eraser.svg create mode 100644 frontend/src/assets/highlighter.svg create mode 100644 frontend/src/assets/trash.svg create mode 100644 frontend/src/components/highlight/EditSwitchButton/index.tsx create mode 100644 frontend/src/components/highlight/EditSwitchButton/style.ts create mode 100644 frontend/src/components/highlight/EditorLineBlock/index.tsx create mode 100644 frontend/src/components/highlight/HighlightButton/index.tsx create mode 100644 frontend/src/components/highlight/HighlightButton/style.ts create mode 100644 frontend/src/components/highlight/HighlightEditor/index.tsx create mode 100644 frontend/src/components/highlight/HighlightEditor/style.ts create mode 100644 frontend/src/components/highlight/HighlightRemoverWrapper/index.tsx create mode 100644 frontend/src/components/highlight/HighlightToggleButtonContainer/index.tsx create mode 100644 frontend/src/components/highlight/Syntax/index.tsx create mode 100644 frontend/src/components/highlight/Syntax/style.ts create mode 100644 frontend/src/constants/highlight.ts create mode 100644 frontend/src/constants/screenReader.ts create mode 100644 frontend/src/hooks/highlight/index.tsx create mode 100644 frontend/src/hooks/highlight/useCheckHighlight.tsx create mode 100644 frontend/src/hooks/highlight/useHighlight.tsx create mode 100644 frontend/src/hooks/highlight/useHighlightRemoverPosition.tsx create mode 100644 frontend/src/hooks/highlight/useHighlightToggleButtonPosition.tsx create mode 100644 frontend/src/mocks/handlers/collection.ts create mode 100644 frontend/src/pages/EditorTestPage/index.tsx create mode 100644 frontend/src/types/highlight.ts create mode 100644 frontend/src/utils/highlight/highlighList.ts create mode 100644 frontend/src/utils/highlight/index.ts create mode 100644 frontend/src/utils/highlight/selection.ts diff --git a/frontend/src/apis/collection.ts b/frontend/src/apis/collection.ts new file mode 100644 index 000000000..3ba2a2469 --- /dev/null +++ b/frontend/src/apis/collection.ts @@ -0,0 +1,38 @@ +import { EditorAnswerMap, HighlightPostPayload } from '@/types'; + +import createApiErrorMessage from './apiErrorMessageCreator'; +import endPoint from './endpoints'; + +const transformHighlightData = (editorAnswerMap: EditorAnswerMap, questionId: number): HighlightPostPayload => { + // NOTE: 하이라이트가 있는 답변만 서버에 보내줌 + return { + questionId, + highlights: [...editorAnswerMap.values()] + .filter((answer) => answer.lineList.some((line) => line.highlightList.length > 0)) + .map((answer) => ({ + answerId: answer.answerId, + lines: answer.lineList + .filter((line) => line.highlightList.length > 0) + .map((line) => ({ + index: line.lineIndex, + ranges: line.highlightList, + })), + })), + }; +}; + +export const postHighlight = async (editorAnswerMap: EditorAnswerMap, questionId: number) => { + const postingData = transformHighlightData(editorAnswerMap, questionId); + const response = await fetch(endPoint.postingHighlight, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify(postingData), + }); + + if (!response.ok) { + throw new Error(createApiErrorMessage(response.status)); + } +}; diff --git a/frontend/src/apis/endpoints.ts b/frontend/src/apis/endpoints.ts index 5b251ae0e..5470c788a 100644 --- a/frontend/src/apis/endpoints.ts +++ b/frontend/src/apis/endpoints.ts @@ -68,6 +68,7 @@ const endPoint = { checkingPassword: `${serverUrl}/${VERSION2}/${REVIEW_PASSWORD_API_PARAMS.resource}/${REVIEW_PASSWORD_API_PARAMS.queryString.check}`, gettingReviewGroupData: (reviewRequestCode: string) => `${REVIEW_GROUP_DATA_API_URL}?${REVIEW_GROUP_DATA_API_PARAMS.queryString.reviewRequestCode}=${reviewRequestCode}`, + postingHighlight: `${serverUrl}/${VERSION2}/highlight`, }; export default endPoint; diff --git a/frontend/src/assets/eraser.svg b/frontend/src/assets/eraser.svg new file mode 100644 index 000000000..6504e263a --- /dev/null +++ b/frontend/src/assets/eraser.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/src/assets/highlighter.svg b/frontend/src/assets/highlighter.svg new file mode 100644 index 000000000..b4b4cf0db --- /dev/null +++ b/frontend/src/assets/highlighter.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/src/assets/trash.svg b/frontend/src/assets/trash.svg new file mode 100644 index 000000000..b436cd6f3 --- /dev/null +++ b/frontend/src/assets/trash.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/src/components/highlight/EditSwitchButton/index.tsx b/frontend/src/components/highlight/EditSwitchButton/index.tsx new file mode 100644 index 000000000..ce5f2ac51 --- /dev/null +++ b/frontend/src/components/highlight/EditSwitchButton/index.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +import * as S from './style'; + +interface EditSwitchButtonProps { + isEditable: boolean; + handleEditToggleButton: () => void; +} + +const EditSwitchButton: React.FC = ({ isEditable, handleEditToggleButton }) => { + return ( + + + + ); +}; + +export default EditSwitchButton; diff --git a/frontend/src/components/highlight/EditSwitchButton/style.ts b/frontend/src/components/highlight/EditSwitchButton/style.ts new file mode 100644 index 000000000..160647eb1 --- /dev/null +++ b/frontend/src/components/highlight/EditSwitchButton/style.ts @@ -0,0 +1,28 @@ +import styled from '@emotion/styled'; + +interface EditorSwitchProps { + $isEditable: boolean; +} +export const EditSwitchButton = styled.button` + cursor: pointer; + + width: 4rem; + padding: 0.5rem; + + background-color: ${({ theme, $isEditable }) => ($isEditable ? theme.colors.primary : theme.colors.gray)}; + border-radius: 3.4rem; + + transition: background-color 0.3s ease; +`; + +export const Circle = styled.div` + transform: translateX(${({ $isEditable }) => ($isEditable ? 0 : '1.5rem')}); + + width: 1.5rem; + height: 1.5rem; + + background-color: ${({ theme }) => theme.colors.white}; + border-radius: 50%; + + transition: transform 0.4s ease-in-out; +`; diff --git a/frontend/src/components/highlight/EditorLineBlock/index.tsx b/frontend/src/components/highlight/EditorLineBlock/index.tsx new file mode 100644 index 000000000..cf6c533a6 --- /dev/null +++ b/frontend/src/components/highlight/EditorLineBlock/index.tsx @@ -0,0 +1,69 @@ +import { EDITOR_LINE_CLASS_NAME } from '@/constants'; +import { EditorLine, HighlightRange } from '@/types'; + +import Syntax from '../Syntax'; + +interface EditorLineBlockProps { + line: EditorLine; + lineIndex: number; +} + +const EditorLineBlock = ({ line, lineIndex }: EditorLineBlockProps) => { + const { text, highlightList } = line; + + const renderSentenceList = () => { + if (!highlightList.length) { + return ; + } + return renderStyledSentenceList(); + }; + interface SplitTextWithHighlightListParams { + text: string; + highlightList: HighlightRange[]; + } + + /** + * 하이라이트에 따라, 블록의 글자를 하이라이트 적용되는 부분과 그렇지 않은 부분으로 나누는 함수 + */ + const splitTextWithHighlightList = ({ text, highlightList }: SplitTextWithHighlightListParams) => { + const result: { text: string; highlightRange: HighlightRange | undefined }[] = []; + let currentIndex = 0; + + highlightList.forEach(({ startIndex, endIndex }) => { + if (currentIndex < startIndex) { + result.push({ highlightRange: undefined, text: text.slice(currentIndex, startIndex) }); + } + result.push({ highlightRange: { startIndex, endIndex }, text: text.slice(startIndex, endIndex + 1) }); + currentIndex = endIndex + 1; + }); + + if (currentIndex < text.length) { + result.push({ highlightRange: undefined, text: text.slice(currentIndex) }); + } + return result; + }; + + /** + * 하이라이트 적용 여부를 반영한 Syntax 컴포넌트를 렌더링하는 함수 + */ + const renderStyledSentenceList = () => { + const highlightedTextList = splitTextWithHighlightList({ text, highlightList }); + const key = `${EDITOR_LINE_CLASS_NAME}-${lineIndex}__span`; + + return ( + <> + {highlightedTextList.map(({ text, highlightRange }, i) => ( + + ))} + + ); + }; + + return ( +

+ {renderSentenceList()} +

+ ); +}; + +export default EditorLineBlock; diff --git a/frontend/src/components/highlight/HighlightButton/index.tsx b/frontend/src/components/highlight/HighlightButton/index.tsx new file mode 100644 index 000000000..b78b7357a --- /dev/null +++ b/frontend/src/components/highlight/HighlightButton/index.tsx @@ -0,0 +1,78 @@ +import EraserIcon from '@/assets/eraser.svg'; +import HighlighterIcon from '@/assets/highlighter.svg'; +import TrashIcon from '@/assets/trash.svg'; +import { + HIGHLIGHT__TOGGLE_BUTTON_CLASS_NAME, + HIGHLIGHT_BUTTON_SIZE, + HIGHLIGHT_REMOVER_CLASS_NAME, + SR_ONLY, +} from '@/constants'; +import { Position } from '@/types'; + +import * as S from './style'; + +interface HighlighterButtonProps { + position: Position; + addHighlight: () => void; +} + +const HighlighterButton = ({ addHighlight, position }: HighlighterButtonProps) => { + return ( + + 하이라이트 추가 버튼 + + + + ); +}; + +interface HighlightDragRemovalProps { + removeHighlightByDrag: () => void; + position: Position; +} + +const HighlightDragRemoval = ({ removeHighlightByDrag, position }: HighlightDragRemovalProps) => { + return ( + + 하이라이트 삭제 버튼 + + + ); +}; + +interface HighlightClickRemovalProps { + removeHighlightByClick: () => void; + position: Position; +} + +const HighlightClickRemoval = ({ removeHighlightByClick, position }: HighlightClickRemovalProps) => { + return ( + + 하이라이트 삭제 버튼 + + + ); +}; + +const HighlightButton = { + highlighter: HighlighterButton, + dragRemoval: HighlightDragRemoval, + clickRemoval: HighlightClickRemoval, +}; + +export default HighlightButton; diff --git a/frontend/src/components/highlight/HighlightButton/style.ts b/frontend/src/components/highlight/HighlightButton/style.ts new file mode 100644 index 000000000..57dd8f3cc --- /dev/null +++ b/frontend/src/components/highlight/HighlightButton/style.ts @@ -0,0 +1,36 @@ +import styled from '@emotion/styled'; + +import { Position } from '@/types'; + +export const Button = styled.button<{ $position: Position; $width: number }>` + position: absolute; + top: ${(props) => props.$position.top}; + left: ${(props) => props.$position.left}; + + display: flex; + gap: 0.8rem; + + width: ${(props) => `${props.$width / 10}rem`}; + padding: 0.5rem 0.8rem; + + background-color: ${({ theme }) => theme.colors.white}; + border-radius: ${({ theme }) => theme.borderRadius.basic}; + -webkit-box-shadow: 0 0 1.4rem -0.2rem #343434; + box-shadow: 0 0 1.4rem -0.2rem #343434; + + &:hover { + background-color: ${({ theme }) => theme.colors.palePurple}; + } +`; + +export const ButtonIcon = styled.img` + width: 1.5rem; + height: 1.5rem; +`; + +export const Color = styled.div` + width: 1.5rem; + height: 1.5rem; + background-color: ${({ theme }) => theme.colors.primary}; + border-radius: 50%; +`; diff --git a/frontend/src/components/highlight/HighlightEditor/index.tsx b/frontend/src/components/highlight/HighlightEditor/index.tsx new file mode 100644 index 000000000..acbf6d298 --- /dev/null +++ b/frontend/src/components/highlight/HighlightEditor/index.tsx @@ -0,0 +1,127 @@ +import { useEffect, useRef, useState } from 'react'; + +import { + EDITOR_ANSWER_CLASS_NAME, + EDITOR_LINE_CLASS_NAME, + HIGHLIGHT__TOGGLE_BUTTON_CLASS_NAME, + HIGHLIGHT_REMOVER_CLASS_NAME, +} from '@/constants'; +import { + useHighlightToggleButtonPosition, + useHighlight, + useCheckHighlight, + useHighlightRemoverPosition, +} from '@/hooks'; +import { ReviewAnswerResponseData } from '@/types'; +import { findSelectionInfo } from '@/utils'; + +import EditorLineBlock from '../EditorLineBlock'; +import EditSwitchButton from '../EditSwitchButton'; +import HighlightRemoverWrapper from '../HighlightRemoverWrapper'; +import HighlightToggleButtonContainer from '../HighlightToggleButtonContainer'; + +import * as S from './style'; + +interface HighlightEditorProps { + questionId: number; + answerList: ReviewAnswerResponseData[]; +} + +const HighlightEditor = ({ questionId, answerList }: HighlightEditorProps) => { + const editorRef = useRef(null); + const [isEditable, setIsEditable] = useState(false); + const { isAddingHighlight, checkHighlight } = useCheckHighlight(); + + const handleEditToggleButton = () => { + setIsEditable((prev) => !prev); + }; + + const { highlightToggleButtonPosition, hideHighlightToggleButton, updateHighlightToggleButtonPosition } = + useHighlightToggleButtonPosition({ + isEditable, + editorRef, + }); + + const { removerPosition, hideRemover, updateRemoverPosition } = useHighlightRemoverPosition({ + isEditable, + editorRef, + }); + + const { + editorAnswerMap, + addHighlight, + removeHighlightByDrag, + handleClickBlockList, + removeHighlightByClick, + removalTarget, + } = useHighlight({ + questionId, + answerList, + isEditable, + hideHighlightToggleButton, + hideRemover, + updateRemoverPosition, + }); + + const handleMouseDown = (e: MouseEvent) => { + if (!isEditable) return; + + const isInButton = (e.target as HTMLElement).closest(`.${HIGHLIGHT__TOGGLE_BUTTON_CLASS_NAME}`); + const isNotHighlightRemover = (e.target as HTMLElement).closest(`.${HIGHLIGHT_REMOVER_CLASS_NAME}`); + + if (!isInButton) hideHighlightToggleButton(); + if (!isNotHighlightRemover) hideRemover(); + }; + + const handleMouseUp = () => { + if (!isEditable) return; + const info = findSelectionInfo(); + if (!info) return; + + const isAddingHighlight = checkHighlight(info); + updateHighlightToggleButtonPosition({ info, isAddingHighlight }); + }; + + useEffect(() => { + document.addEventListener('mouseup', handleMouseUp); + document.addEventListener('mousedown', handleMouseDown); + return () => { + document.removeEventListener('mouseup', handleMouseUp); + document.removeEventListener('mousedown', handleMouseDown); + }; + }, [isEditable]); + + return ( +
+ + + + {[...editorAnswerMap.values()].map(({ answerId, answerIndex, lineList }) => ( +
+ {lineList.map((line, index) => ( + + ))} +
+ ))} + + {isEditable && highlightToggleButtonPosition && ( + + )} + {isEditable && removalTarget && removerPosition && ( + + )} +
+ ); +}; + +export default HighlightEditor; diff --git a/frontend/src/components/highlight/HighlightEditor/style.ts b/frontend/src/components/highlight/HighlightEditor/style.ts new file mode 100644 index 000000000..f1069f686 --- /dev/null +++ b/frontend/src/components/highlight/HighlightEditor/style.ts @@ -0,0 +1,8 @@ +import styled from '@emotion/styled'; + +export const SwitchButtonWrapper = styled.div` + display: flex; + justify-content: end; + width: 100%; + margin-bottom: 1rem; +`; diff --git a/frontend/src/components/highlight/HighlightRemoverWrapper/index.tsx b/frontend/src/components/highlight/HighlightRemoverWrapper/index.tsx new file mode 100644 index 000000000..42a38f87c --- /dev/null +++ b/frontend/src/components/highlight/HighlightRemoverWrapper/index.tsx @@ -0,0 +1,16 @@ +import { Position } from '@/types'; + +import HighlightButton from '../HighlightButton'; + +interface HighlightRemoverWrapperProps { + buttonPosition: Position; + removeHighlightByClick: () => void; +} +/** + * 하이라이트 된 span 태그 클릭 시, 해당 하이라이트를 삭제할 수 있는 버튼을 띄우는 컴포넌트 + */ +const HighlightRemoverWrapper = ({ buttonPosition, removeHighlightByClick }: HighlightRemoverWrapperProps) => { + return ; +}; + +export default HighlightRemoverWrapper; diff --git a/frontend/src/components/highlight/HighlightToggleButtonContainer/index.tsx b/frontend/src/components/highlight/HighlightToggleButtonContainer/index.tsx new file mode 100644 index 000000000..6830091cb --- /dev/null +++ b/frontend/src/components/highlight/HighlightToggleButtonContainer/index.tsx @@ -0,0 +1,31 @@ +import { Position } from '@/types'; + +import HighlightButton from '../HighlightButton'; + +interface HighlightToggleButtonContainerProps { + buttonPosition: Position; + isAddingHighlight: boolean; + addHighlight: () => void; + removeHighlightByDrag: () => void; +} +/** + *선택된 영역의 하이라이트 적용 여부에 따라 추가 또는 삭제 버튼을 보여주는 컴포넌트 + */ +const HighlightToggleButtonContainer = ({ + buttonPosition, + isAddingHighlight, + addHighlight, + removeHighlightByDrag, +}: HighlightToggleButtonContainerProps) => { + return ( + <> + {isAddingHighlight ? ( + + ) : ( + + )} + + ); +}; + +export default HighlightToggleButtonContainer; diff --git a/frontend/src/components/highlight/Syntax/index.tsx b/frontend/src/components/highlight/Syntax/index.tsx new file mode 100644 index 000000000..2b47895ec --- /dev/null +++ b/frontend/src/components/highlight/Syntax/index.tsx @@ -0,0 +1,34 @@ +import { HIGHLIGHT_SPAN_CLASS_NAME, SYNTAX_BASIC_CLASS_NAME } from '@/constants'; +import { HighlightRange } from '@/types'; + +import * as S from './style'; +interface SyntaxProps { + text: string; + spanIndex: number; + highlightRange?: HighlightRange; +} + +const Syntax = ({ text, spanIndex, highlightRange }: SyntaxProps) => { + const className = `${SYNTAX_BASIC_CLASS_NAME} ${highlightRange ? HIGHLIGHT_SPAN_CLASS_NAME : ''}`; + return ( + <> + {highlightRange ? ( + + {text} + + ) : ( + + {text} + + )} + + ); +}; + +export default Syntax; diff --git a/frontend/src/components/highlight/Syntax/style.ts b/frontend/src/components/highlight/Syntax/style.ts new file mode 100644 index 000000000..e028b9bb7 --- /dev/null +++ b/frontend/src/components/highlight/Syntax/style.ts @@ -0,0 +1,10 @@ +import styled from '@emotion/styled'; + +interface SyntaxProps { + $isHighlight: boolean; +} +export const Syntax = styled.span` + line-height: 1.5; + color: ${(props) => (props.$isHighlight ? props.theme.colors.white : 'inherit')}; + background-color: ${(props) => (props.$isHighlight ? props.theme.colors.primary : 'transparent')}; +`; diff --git a/frontend/src/constants/highlight.ts b/frontend/src/constants/highlight.ts new file mode 100644 index 000000000..02549fcc0 --- /dev/null +++ b/frontend/src/constants/highlight.ts @@ -0,0 +1,15 @@ +export const EDITOR_LINE_CLASS_NAME = 'editor__line'; +export const EDITOR_ANSWER_CLASS_NAME = 'editor__answer'; +export const HIGHLIGHT__TOGGLE_BUTTON_CLASS_NAME = 'highlight__toggle-button'; +export const HIGHLIGHT_SPAN_CLASS_NAME = 'highlighted'; +export const SYNTAX_BASIC_CLASS_NAME = 'syntax'; +export const HIGHLIGHT_REMOVER_CLASS_NAME = 'highlight__remover-button'; +// 버튼 관련 +export const HIGHLIGHT_BUTTON_SIZE = { + height: 25, + width: { + buttonWidthColor: 52, + basic: 31, + }, +}; +export const GAP_WIDTH_SELECTION_AND_HIGHLIGHT_BUTTON = 10; diff --git a/frontend/src/constants/index.ts b/frontend/src/constants/index.ts index 5623f6aef..380620f06 100644 --- a/frontend/src/constants/index.ts +++ b/frontend/src/constants/index.ts @@ -3,3 +3,5 @@ export * from './errorMessage'; export * from './review'; export * from './queryKey'; export * from './routerParam'; +export * from './highlight'; +export * from './screenReader'; diff --git a/frontend/src/constants/screenReader.ts b/frontend/src/constants/screenReader.ts new file mode 100644 index 000000000..f33ea0a77 --- /dev/null +++ b/frontend/src/constants/screenReader.ts @@ -0,0 +1 @@ +export const SR_ONLY = 'sr-only'; diff --git a/frontend/src/hooks/highlight/index.tsx b/frontend/src/hooks/highlight/index.tsx new file mode 100644 index 000000000..9b38dd7bc --- /dev/null +++ b/frontend/src/hooks/highlight/index.tsx @@ -0,0 +1,4 @@ +export { default as useHighlight } from './useHighlight'; +export { default as useHighlightToggleButtonPosition } from './useHighlightToggleButtonPosition'; +export { default as useCheckHighlight } from './useCheckHighlight'; +export { default as useHighlightRemoverPosition } from './useHighlightRemoverPosition'; diff --git a/frontend/src/hooks/highlight/useCheckHighlight.tsx b/frontend/src/hooks/highlight/useCheckHighlight.tsx new file mode 100644 index 000000000..13bb1dbf3 --- /dev/null +++ b/frontend/src/hooks/highlight/useCheckHighlight.tsx @@ -0,0 +1,31 @@ +import { useState } from 'react'; + +import { HIGHLIGHT_SPAN_CLASS_NAME, SYNTAX_BASIC_CLASS_NAME } from '@/constants'; +import { EditorSelectionInfo } from '@/utils'; + +const useCheckHighlight = () => { + const [isAddingHighlight, setIsAddingHighlight] = useState(false); + + const checkHighlight = (info: EditorSelectionInfo) => { + const selectedAllSpanList = getAllSpanInSelection(info.selection); + const isNoneHighlight = selectedAllSpanList.some((span) => !span.classList.contains(HIGHLIGHT_SPAN_CLASS_NAME)); + + setIsAddingHighlight(isNoneHighlight); + + return isNoneHighlight; + }; + + const getAllSpanInSelection = (selection: Selection) => { + const range = selection.getRangeAt(0); + const sentenceElList = document.getElementsByClassName(SYNTAX_BASIC_CLASS_NAME); + + return [...sentenceElList].filter((el) => range.intersectsNode(el)); + }; + + return { + isAddingHighlight, + checkHighlight, + }; +}; + +export default useCheckHighlight; diff --git a/frontend/src/hooks/highlight/useHighlight.tsx b/frontend/src/hooks/highlight/useHighlight.tsx new file mode 100644 index 000000000..6308dff3c --- /dev/null +++ b/frontend/src/hooks/highlight/useHighlight.tsx @@ -0,0 +1,461 @@ +import { useState } from 'react'; + +import { postHighlight } from '@/apis/collection'; +import { EDITOR_ANSWER_CLASS_NAME, HIGHLIGHT_SPAN_CLASS_NAME } from '@/constants'; +import { EditorAnswerMap, EditorLine, Highlight, ReviewAnswerResponseData } from '@/types'; +import { + getEndBlockOffset, + getStartBlockOffset, + getRemovedHighlightList, + findSelectionInfo, + getUpdatedBlockByHighlight, + removeSelection, + EditorSelectionInfo, +} from '@/utils'; + +interface UseHighlightProps { + questionId: number; + answerList: ReviewAnswerResponseData[]; + isEditable: boolean; + hideHighlightToggleButton: () => void; + updateRemoverPosition: (rect: DOMRect) => void; + hideRemover: () => void; +} + +interface RemovalTarget { + answerId: number; + lineIndex: number; + highlightIndex: number; +} + +const findBlockHighlightListFromAnswer = (answerHighlightList: Highlight[], lineIndex: number) => { + return answerHighlightList.find((i) => i.lineIndex === lineIndex)?.rangeList || []; +}; +const makeBlockListByText = (content: string, answerHighlightList: Highlight[]): EditorLine[] => { + return content.split('\n').map((text, index) => ({ + lineIndex: index, + text, + highlightList: findBlockHighlightListFromAnswer(answerHighlightList, index), + })); +}; + +const makeInitialEditorAnswerMap = (answerList: ReviewAnswerResponseData[]) => { + const initialEditorAnswerMap: EditorAnswerMap = new Map(); + + answerList.forEach((answer, index) => { + initialEditorAnswerMap.set(answer.id, { + answerId: answer.id, + content: answer.content, + answerIndex: index, + lineList: makeBlockListByText(answer.content, answer.highlights), + }); + }); + + return initialEditorAnswerMap; +}; + +const useHighlight = ({ + questionId, + answerList, + isEditable, + hideHighlightToggleButton, + updateRemoverPosition, + hideRemover, +}: UseHighlightProps) => { + const [editorAnswerMap, setEditorAnswerMap] = useState(makeInitialEditorAnswerMap(answerList)); + // span 클릭 시, 제공되는 형광펜 삭제 기능 타겟 + const [removalTarget, setRemovalTarget] = useState(null); + + /** + * 선택사항, 토글 버튼 지우기 + */ + const resetSelectionAndButton = () => { + removeSelection(); + hideHighlightToggleButton(); + }; + + const addHighlight = async () => { + const selectionInfo = findSelectionInfo(); + if (!selectionInfo) return; + const newEditorAnswerMap = selectionInfo.isSameAnswer + ? addSingleAnswerHighlight(selectionInfo) + : addMultipleAnswerHighlight(selectionInfo); + if (!newEditorAnswerMap) return; + // TODO: 데이터 요청 후, 성공 시 업데이트 하기 + + try { + await postHighlight(newEditorAnswerMap, questionId); + setEditorAnswerMap(newEditorAnswerMap); + + resetSelectionAndButton(); + } catch (error) { + // TODO: 자세한 에러처리는 나중애 + console.error(error); + } + }; + + const addMultipleAnswerHighlight = (selectionInfo: EditorSelectionInfo) => { + const { startAnswer, endAnswer } = selectionInfo; + const newEditorAnswerMap = new Map(editorAnswerMap); + if (!startAnswer || !endAnswer) return; + + [...newEditorAnswerMap.keys()].forEach((answerId, answerIndex) => { + if (startAnswer.id === answerId) { + const { lineIndex, offset } = startAnswer; + const targetAnswer = newEditorAnswerMap.get(answerId); + + if (!targetAnswer) return; + const { lineList } = targetAnswer; + + const newLineList: EditorLine[] = lineList.map((line, index) => { + if (index < lineIndex) return line; + + if (index > lineIndex) { + return { + ...line, + highlightList: [{ startIndex: 0, endIndex: line.text.length - 1 }], + }; + } + return getUpdatedBlockByHighlight({ + blockTextLength: line.text.length, + lineIndex: index, + startIndex: offset, + endIndex: line.text.length - 1, + lineList, + }); + }); + + newEditorAnswerMap.set(answerId, { ...targetAnswer, lineList: newLineList }); + } + + if (startAnswer.index < answerIndex && endAnswer.index > answerIndex) { + const targetAnswer = newEditorAnswerMap.get(answerId); + + if (!targetAnswer) return; + const { lineList } = targetAnswer; + + const newLineList = lineList.map((block) => ({ + ...block, + highlightList: [{ startIndex: 0, endIndex: block.text.length - 1 }], + })); + + newEditorAnswerMap.set(answerId, { ...targetAnswer, lineList: newLineList }); + } + + if (endAnswer.id === answerId) { + const { lineIndex, offset } = endAnswer; + const targetAnswer = newEditorAnswerMap.get(answerId); + + if (!targetAnswer) return; + const { lineList } = targetAnswer; + + const newLineList = lineList.map((block, index) => { + if (index > lineIndex) return block; + if (index < lineIndex) { + return { + ...block, + highlightList: [{ startIndex: 0, endIndex: block.text.length - 1 }], + }; + } + + return getUpdatedBlockByHighlight({ + blockTextLength: block.text.length, + lineIndex: index, + startIndex: 0, + endIndex: offset, + lineList, + }); + }); + + newEditorAnswerMap.set(answerId, { ...targetAnswer, lineList: newLineList }); + } + }); + + return newEditorAnswerMap; + }; + + const addSingleAnswerHighlight = (selectionInfo: EditorSelectionInfo) => { + const { startBlockIndex, endBlockIndex, startAnswer } = selectionInfo; + if (!startAnswer) return; + + const newEditorAnswerMap = new Map(editorAnswerMap); + const answerId = startAnswer.id; + const targetAnswer = newEditorAnswerMap.get(answerId); + + if (!targetAnswer) return; + + const newLineList: EditorLine[] = targetAnswer.lineList.map((block, index, array) => { + if (index < startBlockIndex) return block; + if (index > endBlockIndex) return block; + if (index === startBlockIndex) { + const { startIndex, endIndex } = getStartBlockOffset(selectionInfo, block); + + return getUpdatedBlockByHighlight({ + blockTextLength: block.text.length, + lineIndex: index, + startIndex, + endIndex, + lineList: array, + }); + } + + if (index === endBlockIndex) { + const endIndex = getEndBlockOffset(selectionInfo); + + return getUpdatedBlockByHighlight({ + blockTextLength: block.text.length, + lineIndex: index, + startIndex: 0, + endIndex, + lineList: array, + }); + } + return { + ...block, + highlightList: [{ startIndex: 0, endIndex: block.text.length }], + }; + }); + + newEditorAnswerMap.set(answerId, { ...targetAnswer, lineList: newLineList }); + return newEditorAnswerMap; + }; + + const removeHighlightByDrag = async () => { + const selectionInfo = findSelectionInfo(); + if (!selectionInfo) return; + + const newEditorAnswerMap = selectionInfo.isSameAnswer + ? removeSingleAnswerHighlight(selectionInfo) + : removeMultipleAnswerHighlight(selectionInfo); + + if (!newEditorAnswerMap) return; + + try { + await postHighlight(newEditorAnswerMap, questionId); + + setEditorAnswerMap(newEditorAnswerMap); + // 선택사항, 토글 버튼 지우기 + resetSelectionAndButton(); + } catch (error) { + // 자세한 에러처리는 나중애 + console.error(error); + } + }; + + const removeSingleAnswerHighlight = (selectionInfo: EditorSelectionInfo) => { + const { startBlockIndex, endBlockIndex, startAnswer } = selectionInfo; + if (!startAnswer) return; + + const newEditorAnswerMap = new Map(editorAnswerMap); + const answerId = startAnswer.id; + const targetAnswer = newEditorAnswerMap.get(answerId); + + if (!targetAnswer) return; + + const newLineList = targetAnswer.lineList.map((line, index) => { + if (index < startBlockIndex) return line; + if (index > endBlockIndex) return line; + if (index === startBlockIndex) { + const { startIndex, endIndex } = getStartBlockOffset(selectionInfo, line); + + return { + ...line, + highlightList: getRemovedHighlightList({ + blockTextLength: line.text.length, + highlightList: line.highlightList, + startIndex, + endIndex, + }), + }; + } + if (index === endBlockIndex) { + const endIndex = getEndBlockOffset(selectionInfo); + return { + ...line, + highlightList: getRemovedHighlightList({ + blockTextLength: line.text.length, + highlightList: line.highlightList, + startIndex: 0, + endIndex, + }), + }; + } + return { + ...line, + highlightList: [], + }; + }); + + newEditorAnswerMap.set(answerId, { ...targetAnswer, lineList: newLineList }); + return newEditorAnswerMap; + }; + + const removeMultipleAnswerHighlight = (selectionInfo: EditorSelectionInfo) => { + const { startAnswer, endAnswer } = selectionInfo; + const newEditorAnswerMap = new Map(editorAnswerMap); + if (!startAnswer || !endAnswer) return; + + [...newEditorAnswerMap.keys()].forEach((answerId, answerIndex) => { + if (answerId === startAnswer.id) { + const { lineIndex, offset } = startAnswer; + const targetAnswer = newEditorAnswerMap.get(answerId); + + if (!targetAnswer) return; + const { lineList } = targetAnswer; + + const newLineList = lineList.map((line, index) => { + if (index < lineIndex) return line; + + if (index > lineIndex) { + return { + ...line, + highlightList: [], + }; + } + return { + ...line, + highlightList: getRemovedHighlightList({ + blockTextLength: line.text.length, + highlightList: line.highlightList, + startIndex: offset, + endIndex: line.text.length - 1, + }), + }; + }); + + newEditorAnswerMap.set(answerId, { ...targetAnswer, lineList: newLineList }); + } + if (answerId === endAnswer.id) { + const { lineIndex, offset } = endAnswer; + const targetAnswer = newEditorAnswerMap.get(answerId); + + if (!targetAnswer) return; + const { lineList } = targetAnswer; + + const newLineList = lineList.map((line, index) => { + if (index > lineIndex) return line; + + if (index < lineIndex) { + return { + ...line, + highlightList: [], + }; + } + return { + ...line, + highlightList: getRemovedHighlightList({ + blockTextLength: line.text.length, + highlightList: line.highlightList, + startIndex: 0, + endIndex: offset, + }), + }; + }); + + newEditorAnswerMap.set(answerId, { ...targetAnswer, lineList: newLineList }); + } + + if (answerIndex > startAnswer.index && answerIndex < endAnswer.index) { + const targetAnswer = newEditorAnswerMap.get(answerId); + if (!targetAnswer) return; + + const newLineList: EditorLine[] = targetAnswer.lineList.map((block) => ({ + ...block, + highlightList: [], + })); + newEditorAnswerMap.set(answerId, { ...targetAnswer, lineList: newLineList }); + } + }); + + return newEditorAnswerMap; + }; + + const isSingleCharacterSelected = () => { + const selection = document.getSelection(); + + if (selection) { + const { anchorNode, anchorOffset, focusNode, focusOffset } = selection; + const isSameSelectedNode = anchorNode === focusNode && Math.abs(anchorOffset - focusOffset) === 1; + + return isSameSelectedNode; + } + return false; + }; + const handleClickBlockList = (event: React.MouseEvent) => { + if (!isEditable) return; + + const isSameSelectedNode = isSingleCharacterSelected(); + + if (isSameSelectedNode) return; + + const target = event.target as HTMLElement; + if (!target.classList.contains(HIGHLIGHT_SPAN_CLASS_NAME)) return; + + const answerElement = target.closest(`.${EDITOR_ANSWER_CLASS_NAME}`); + if (!answerElement) return; + const id = answerElement.getAttribute('data-answer')?.split('-')[0]; + if (!id) return; + const targetAnswer = editorAnswerMap.get(Number(id)); + if (!targetAnswer) return; + + const rect = target.getClientRects()[0]; + if (!target.classList.contains(HIGHLIGHT_SPAN_CLASS_NAME)) return; + const lineIndex = target.parentElement?.getAttribute('data-index'); + const start = target.getAttribute('data-highlight-start'); + const end = target.getAttribute('data-highlight-end'); + if (!lineIndex || !start || !end) return; + const { highlightList } = targetAnswer.lineList[Number(lineIndex)]; + const highlightIndex = highlightList.findIndex((i) => i.startIndex === Number(start) && i.endIndex === Number(end)); + + setRemovalTarget({ + answerId: targetAnswer.answerId, + lineIndex: Number(lineIndex), + highlightIndex: Number(highlightIndex), + }); + + updateRemoverPosition(rect); + }; + + const removeHighlightByClick = async () => { + if (!removalTarget) return; + + const { answerId, lineIndex, highlightIndex } = removalTarget; + + const newEditorAnswerMap = new Map(editorAnswerMap); + const targetAnswer = newEditorAnswerMap.get(answerId); + if (!targetAnswer) return; + + const newLineList = [...targetAnswer.lineList]; + const targetBlock = newLineList[lineIndex]; + const newHighlightList = [...targetBlock.highlightList]; + + newHighlightList.splice(highlightIndex, 1); + const newTargetBlock: EditorLine = { ...targetBlock, highlightList: newHighlightList }; + + newLineList.splice(lineIndex, 1, newTargetBlock); + newEditorAnswerMap.set(answerId, { ...targetAnswer, lineList: newLineList }); + + try { + await postHighlight(newEditorAnswerMap, questionId); + setEditorAnswerMap(newEditorAnswerMap); + + // 초기화 + hideRemover(); + setRemovalTarget(null); + } catch (error) { + //TODO: 자세한 에러처리는 나중애 + console.error(error); + } + }; + + return { + editorAnswerMap, + addHighlight, + removeHighlightByDrag, + handleClickBlockList, + removeHighlightByClick, + removalTarget, + }; +}; + +export default useHighlight; diff --git a/frontend/src/hooks/highlight/useHighlightRemoverPosition.tsx b/frontend/src/hooks/highlight/useHighlightRemoverPosition.tsx new file mode 100644 index 000000000..eecfed581 --- /dev/null +++ b/frontend/src/hooks/highlight/useHighlightRemoverPosition.tsx @@ -0,0 +1,45 @@ +import { useLayoutEffect, useState } from 'react'; + +import { GAP_WIDTH_SELECTION_AND_HIGHLIGHT_BUTTON, HIGHLIGHT_BUTTON_SIZE } from '@/constants'; +import { Position } from '@/types'; + +interface UseHighlightRemoverPositionProps { + isEditable: boolean; + editorRef: React.RefObject; +} +const useHighlightRemoverPosition = ({ isEditable, editorRef }: UseHighlightRemoverPositionProps) => { + const [removerPosition, setRemoverPosition] = useState(null); + + const updateRemoverPosition = (rect: DOMRect) => { + const editorRect = editorRef.current?.getClientRects()[0]; + if (!editorRect) return; + const top = rect.bottom - editorRect.top; + const left = rect.right - editorRect.left; + + const buttonWidth = HIGHLIGHT_BUTTON_SIZE.width.basic; + + const isOverEditorArea = editorRect.right < rect.right + buttonWidth; + const topOffsetFromParent = isOverEditorArea ? top + GAP_WIDTH_SELECTION_AND_HIGHLIGHT_BUTTON : top; + const leftOffsetFromParent = isOverEditorArea ? editorRect.width - buttonWidth : left; + + setRemoverPosition({ + top: ` + ${topOffsetFromParent / 10}rem`, + left: `${leftOffsetFromParent / 10}rem`, + }); + }; + + const hideRemover = () => setRemoverPosition(null); + + useLayoutEffect(() => { + if (!isEditable) hideRemover(); + }, [isEditable]); + + return { + removerPosition, + updateRemoverPosition, + hideRemover, + }; +}; + +export default useHighlightRemoverPosition; diff --git a/frontend/src/hooks/highlight/useHighlightToggleButtonPosition.tsx b/frontend/src/hooks/highlight/useHighlightToggleButtonPosition.tsx new file mode 100644 index 000000000..806c3285e --- /dev/null +++ b/frontend/src/hooks/highlight/useHighlightToggleButtonPosition.tsx @@ -0,0 +1,75 @@ +import { useLayoutEffect, useState } from 'react'; + +import { GAP_WIDTH_SELECTION_AND_HIGHLIGHT_BUTTON, HIGHLIGHT_BUTTON_SIZE } from '@/constants'; +import { Position } from '@/types'; +import { EditorSelectionInfo } from '@/utils'; + +interface UseHighlightButtonPositionProps { + isEditable: boolean; + editorRef: React.RefObject; +} + +const useHighlightToggleButtonPosition = ({ isEditable, editorRef }: UseHighlightButtonPositionProps) => { + const [highlightToggleButtonPosition, setHighlightToggleButtonPosition] = useState(null); + + const hideHighlightToggleButton = () => setHighlightToggleButtonPosition(null); + + interface CalculateEndPositionParams { + info: EditorSelectionInfo; + isAddingHighlight: boolean; + } + const calculateEndPosition = ({ info, isAddingHighlight }: CalculateEndPositionParams) => { + const { selection, isForwardDrag, startBlock } = info; + if (!editorRef.current) return; + const range = selection.getRangeAt(0); + const rects = range.getClientRects(); + const editorRect = editorRef.current.getClientRects()[0]; + + if (rects.length === 0) return; + + // 드래그 방향에 따른 마지막 rect의 좌표 정보를 가져옴 (마우스가 놓인 최종 지점) + const lastRect = rects[isForwardDrag ? rects.length - 1 : 0]; + const buttonHight = HIGHLIGHT_BUTTON_SIZE.height; + const { basic: buttonBasicWidth, buttonWidthColor: addButtonWidth } = HIGHLIGHT_BUTTON_SIZE.width; + const buttonWidth = isAddingHighlight ? addButtonWidth : buttonBasicWidth; + + const rectLeft = isForwardDrag ? lastRect.right : lastRect.left; + const left = rectLeft - editorRect.left; + const top = + lastRect.top - + (isForwardDrag ? 0 : startBlock.clientHeight + buttonHight + GAP_WIDTH_SELECTION_AND_HIGHLIGHT_BUTTON) - + editorRect.top + + buttonHight; + + const isOverEditorArea = editorRect.right < rectLeft + buttonWidth; + const leftOffsetFromParent = isOverEditorArea ? editorRect.width - buttonWidth : left; + const topOffsetFromParent = top; + const endPosition: Position = { + left: `${leftOffsetFromParent / 10}rem`, + top: `${topOffsetFromParent / 10}rem`, + }; + + return endPosition; + }; + + const updateHighlightToggleButtonPosition = ({ info, isAddingHighlight }: CalculateEndPositionParams) => { + const endPosition = calculateEndPosition({ info, isAddingHighlight }); + if (!endPosition) return console.error('endPosition을 찾을 수 없어요.'); + + setHighlightToggleButtonPosition(endPosition); + }; + + useLayoutEffect(() => { + if (!isEditable) hideHighlightToggleButton(); + }, [isEditable]); + + useLayoutEffect(() => {}); + + return { + highlightToggleButtonPosition, + hideHighlightToggleButton, + updateHighlightToggleButtonPosition, + }; +}; + +export default useHighlightToggleButtonPosition; diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index 5532776bd..657a1818a 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -9,3 +9,4 @@ export { default as useTopButton } from './useTopButton'; export * from './review'; export * from './reviewGroup'; export * from './modal'; +export * from './highlight'; diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index da75f7b49..2621ee3b5 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -11,6 +11,7 @@ import App from '@/App'; import { ErrorSuspenseContainer } from './components'; import { API_ERROR_MESSAGE, ROUTE_PARAM } from './constants'; import { ROUTE } from './constants/route'; +import EditorTestPage from './pages/EditorTestPage'; import globalStyles from './styles/globalStyles'; import theme from './styles/theme'; @@ -76,10 +77,6 @@ const router = createBrowserRouter([ path: '', element: , }, - { - path: 'user', - element:
user
, - }, { path: `${ROUTE.reviewWriting}/:${ROUTE_PARAM.reviewRequestCode}`, element: }, { path: `${ROUTE.reviewWritingComplete}/:${ROUTE_PARAM.reviewRequestCode}`, @@ -102,9 +99,10 @@ const router = createBrowserRouter([ ), }, { - path: `${ROUTE.reviewCollection}/:${ROUTE_PARAM.reviewRequestCode}`, - element: , + path: `/editor-test`, + element: , }, + { path: `${ROUTE.reviewCollection}/:${ROUTE_PARAM.reviewRequestCode}`, element: }, ], }, ]); diff --git a/frontend/src/mocks/handlers/collection.ts b/frontend/src/mocks/handlers/collection.ts new file mode 100644 index 000000000..360f85518 --- /dev/null +++ b/frontend/src/mocks/handlers/collection.ts @@ -0,0 +1,11 @@ +import { http, HttpResponse } from 'msw'; + +import endPoint from '@/apis/endpoints'; + +const postMockHighlight = () => + http.post(endPoint.postingHighlight, async () => { + return HttpResponse.json({ status: 200 }); + }); + +const collectionHandler = [postMockHighlight()]; +export default collectionHandler; diff --git a/frontend/src/mocks/handlers/index.ts b/frontend/src/mocks/handlers/index.ts index 52f58c559..64619b73c 100644 --- a/frontend/src/mocks/handlers/index.ts +++ b/frontend/src/mocks/handlers/index.ts @@ -1,6 +1,7 @@ +import collectionHandler from './collection'; import groupHandler from './group'; import reviewHandler from './review'; -const handlers = [...reviewHandler, ...groupHandler]; +const handlers = [...reviewHandler, ...groupHandler, ...collectionHandler]; export default handlers; diff --git a/frontend/src/pages/EditorTestPage/index.tsx b/frontend/src/pages/EditorTestPage/index.tsx new file mode 100644 index 000000000..51cc48689 --- /dev/null +++ b/frontend/src/pages/EditorTestPage/index.tsx @@ -0,0 +1,27 @@ +import HighlightEditor from '@/components/highlight/HighlightEditor'; + +const MOCK_DATA = + '나는 말야, 버릇이 하나있어, 그건 매일 잠에 들 시간마다잘 모아둔 기억 조각들 잡히는 걸 집은 후 혼자 조용히 꼬꼬무\n이걸 난\n이름으로 지었어, 고민,\n아무튼, 뭐, 오늘은 하필이면\n너가 스쳐버려서 우리였을 때로\n우리 정말 좋았던 그때로\n우리의 에피소드가 찬란하게 막을 연다\n배경은 너의 집 앞, 첫 데이트가 끝난\n둘만의 에피소드가 참 예쁜 얘기로 시작\n자작자작, 조심스런 대화, 그새 늦은 시간 조심스런 대화, 그새 늦은 시간 조심스런 대화, 그새 늦은 시간 조심스'; + +const EditorTestPage = () => { + return ( +
+

형광펜 기능 테스트 페이지

+ + +
+ ); +}; + +export default EditorTestPage; diff --git a/frontend/src/styles/globalStyles.ts b/frontend/src/styles/globalStyles.ts index c316123fc..20892810e 100644 --- a/frontend/src/styles/globalStyles.ts +++ b/frontend/src/styles/globalStyles.ts @@ -49,6 +49,12 @@ const globalStyles = (theme: Theme) => css` background: transparent; } } + + .sr-only { + position: fixed; + top: -999rem; + left: -999rem; + } `; export default globalStyles; diff --git a/frontend/src/types/highlight.ts b/frontend/src/types/highlight.ts new file mode 100644 index 000000000..e44049bb6 --- /dev/null +++ b/frontend/src/types/highlight.ts @@ -0,0 +1,64 @@ +/** + * 하이라이트가 적용된 구문에서 하이라이트가 시작되는 지점과 끝점 + */ +export interface HighlightRange { + startIndex: number; + endIndex: number; +} + +// NOTE: 서버에서는 하이라이트가 적용된 문장에 대한 하이라이트 정보만 보내주지만, 클라이언트는 뷰에 하이라이트 적용여부를 보여주는 편의성을 위핸 모든 문장 index를 가지지만, highlightList가 빈배열이면 하이아리트 적용이 안되어있고 빈배열이 아니면 하이라이트가 있습니다 + +// 서버에서 내려주고, 받는 데이터 타입 +/** + * 하이라이트가 적용된 block(=문장)의 index, 하이라이트 범위 배열을 가진 타입 + */ +export interface Highlight { + lineIndex: number; // 하이라이트가 적용된 문장 index + // 서버에서는 ranges로 줌 + rangeList: HighlightRange[]; +} + +// 서버에서 보내주는 리뷰 모아보기 데이터 +export interface ReviewAnswerResponseData { + id: number; + content: string; + highlights: Highlight[]; +} + +/** + * 하이라이트 변경 시, 서버에 보내는 하이라이트 정보 타입 + */ +export interface HighlightPostPayload { + questionId: number; + highlights: { + answerId: number; + //하이라이트가 적용된 블럭의 정보를 보내줌 + lines: { + index: number; // 하이라이트가 적용된 구문의 index + ranges: HighlightRange[]; + }[]; + }[]; +} +// 클라이언트에서 사용하는 형광펜 대상 주관식 답변 타입 +export interface EditorAnswer { + content: string; + answerId: number; + answerIndex: number; + lineList: EditorLine[]; +} + +export type EditorAnswerMap = Map; + +/** + * 구문에 대한 정보 + */ +export interface EditorLine { + lineIndex: number; // 구문 index + text: string; // 구문 글자 + highlightList: HighlightRange[]; // 하이라이트 정보, 하이라이트 정보가 없으면 빈배열 +} + +export interface Position { + top: string; + left: string; +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 9b7213a5d..3388936fc 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -4,4 +4,4 @@ export * from './emotion'; export * from './styles'; export * from './essentialPropsWithChildren'; export * from './reviewGroup'; -export * from './urlGeneratorForm'; +export * from './highlight'; diff --git a/frontend/src/utils/highlight/highlighList.ts b/frontend/src/utils/highlight/highlighList.ts new file mode 100644 index 000000000..bd3ce3d1c --- /dev/null +++ b/frontend/src/utils/highlight/highlighList.ts @@ -0,0 +1,145 @@ +import { EditorLine, HighlightRange } from '@/types'; + +interface CreateHighlightBinaryArrayParams { + arrayLength: number; + list: HighlightRange[]; +} +/** + * 하이라이트 적용 여부를 이진법에 따라 표시하는 배열을 생성하는 함수 + * @param list 배열에 표시할 하이라이트 배열 + * @param arrayLength 이진법 배열의 length이자 하이라이트 적용 대상인 block의 글자 수 + */ +const createHighlightBinaryArray = ({ arrayLength, list }: CreateHighlightBinaryArrayParams) => { + const array = '0'.repeat(arrayLength).split(''); + + list.forEach((item) => { + const { startIndex, endIndex } = item; + for (let i = startIndex; i <= endIndex; i++) { + array[i] = '1'; + } + }); + + return array; +}; +/** + * '0','1'로 이루어진 배열을 가지고, highlightList를 만드는 함수 + * 1이 하나 이상일 경우, 시작 index가 start 이고 연속이 끝나는 index가 end + * @param array + */ +const makeHighlightListByConsecutiveOnes = (array: string[]) => { + const result: HighlightRange[] = []; + let startIndex = -1; // 시작점 초기화 (아직 찾지 못한 상태) + + for (let i = 0; i < array.length; i++) { + if (array[i] === '1' && startIndex === -1) { + // 1이 시작되는 지점 + startIndex = i; + } else if ((array[i] === '0' || i === array.length - 1) && startIndex !== -1) { + // 1이 끝나는 지점: 0을 만났거나 배열의 끝에 도달했을 때 + const endIndex = array[i] === '1' ? i : i - 1; + result.push({ startIndex, endIndex }); + startIndex = -1; // 다시 초기화 + } + } + + return result; +}; + +interface MergeHighlightListParams { + blockTextLength: number; + highlightList: HighlightRange[]; + newHighlight: HighlightRange; +} +export const mergeHighlightList = ({ + blockTextLength, + highlightList, + newHighlight, +}: MergeHighlightListParams): HighlightRange[] => { + const array = createHighlightBinaryArray({ arrayLength: blockTextLength, list: highlightList.concat(newHighlight) }); + + return makeHighlightListByConsecutiveOnes(array); +}; + +interface GetUpdatedBlockByHighlightParams { + blockTextLength: number; + lineIndex: number; + startIndex: number; + endIndex: number; + lineList: EditorLine[]; +} + +export const getUpdatedBlockByHighlight = ({ + blockTextLength, + lineIndex, + startIndex, + endIndex, + lineList, +}: GetUpdatedBlockByHighlightParams) => { + const newHighlight: HighlightRange = { startIndex, endIndex }; + const block = lineList[lineIndex]; + const { highlightList } = block; + + return { + ...block, + highlightList: mergeHighlightList({ blockTextLength, highlightList, newHighlight }), + }; +}; + +interface GetRemovedHighlightListParams { + blockTextLength: number; + highlightList: HighlightRange[]; + startIndex: number; // 지우는 영역 시작점 + endIndex: number; // 지우는 영역 끝나는 지점 +} + +/** + * 이미 있는 하이라이트 중, 일부분을 삭제하고 새로운 highlightList를 반환하는 함수 + */ +const getHighlightListAfterPartialRemoval = ({ + blockTextLength, + highlightList, + startIndex, + endIndex, +}: GetRemovedHighlightListParams) => { + const array = createHighlightBinaryArray({ arrayLength: blockTextLength, list: highlightList }); + + //지우기 + for (let i = startIndex; i <= endIndex; i++) { + array[i] = '0'; + } + + return makeHighlightListByConsecutiveOnes(array); +}; + +/** + * 이미 있는 하이라이트 중, 해당 하이라이트를 삭제하고 새로운 highlightList를 반환하는 함수 + */ +const getHighlightListAfterFullyRemoval = ({ + highlightList, + startIndex, + endIndex, +}: Omit) => { + return highlightList.filter(({ startIndex: hStartIndex, endIndex: hEndIndex }) => { + return hEndIndex <= startIndex || hStartIndex >= endIndex; + }); +}; + +/*하이라이트 삭제 함수*/ +export const getRemovedHighlightList = (params: GetRemovedHighlightListParams) => { + const { highlightList, startIndex, endIndex } = params; + // 한 글자만 하이라이트된 것을 삭제하는 겨우 + const isRemoveSingleHighlight = highlightList.find((h) => h.endIndex == endIndex && h.startIndex === startIndex); + + if (isRemoveSingleHighlight) + return highlightList.filter((i) => i.startIndex !== startIndex && i.endIndex !== endIndex); + + const isDeleteHighlightFully = highlightList.find( + (item) => item.startIndex === startIndex && item.endIndex === endIndex, + ); + // 이미 있는 하이라이트 영역을 모두 삭제 경우 + if (isDeleteHighlightFully) { + return getHighlightListAfterFullyRemoval({ highlightList, startIndex, endIndex }); + } + + return getHighlightListAfterPartialRemoval(params); +}; diff --git a/frontend/src/utils/highlight/index.ts b/frontend/src/utils/highlight/index.ts new file mode 100644 index 000000000..beb62ff86 --- /dev/null +++ b/frontend/src/utils/highlight/index.ts @@ -0,0 +1,2 @@ +export * from './highlighList'; +export * from './selection'; diff --git a/frontend/src/utils/highlight/selection.ts b/frontend/src/utils/highlight/selection.ts new file mode 100644 index 000000000..758056eb1 --- /dev/null +++ b/frontend/src/utils/highlight/selection.ts @@ -0,0 +1,243 @@ +import { EDITOR_ANSWER_CLASS_NAME, EDITOR_LINE_CLASS_NAME } from '@/constants'; +import { EditorLine } from '@/types'; + +interface GetSelectionOffsetInBlockParams { + selectionTargetNode: Node | null; + selectionTargetOffset: number; + blockElement: Element; +} +/* + *선택된 텍스트의 block 기준 offset을 계산하는 함수 + */ +export const calculateOffsetInBlock = ({ + selectionTargetNode, + selectionTargetOffset, + blockElement, +}: GetSelectionOffsetInBlockParams) => { + const spanIndex = selectionTargetNode?.parentElement?.getAttribute('data-index'); + + if (!spanIndex) { + console.error(`${selectionTargetNode}에 대한 span의 data-index를 찾을 수 없습니다.`); + return 0; + } + + const spanList = [...blockElement.querySelectorAll('span')]; + const offset = + spanList.slice(0, Number(spanIndex)).reduce((acc, cur) => acc + (cur.textContent?.length || 0), 0) + + selectionTargetOffset; + return offset; +}; + +const getAnswerElementInfo = (element: Element) => { + const info = element + .getAttribute('data-answer') + ?.split('-') + .reduce( + (acc, cur, index) => { + if (index === 0) acc.id = Number(cur); + if (index === 1) acc.index = Number(cur); + return acc; + }, + { id: 0, index: 0 }, + ); + + return info; +}; + +interface BlockData { + block: Element; + index: number; +} +interface GetAnswerInfoParams { + anchorBlockData: BlockData; + focusBlockData: BlockData; + anchorOffset: number; + focusOffset: number; +} +export const getAnswerInfo = ({ anchorBlockData, focusBlockData, anchorOffset, focusOffset }: GetAnswerInfoParams) => { + const anchorAnswerElement = anchorBlockData.block.closest(`.${EDITOR_ANSWER_CLASS_NAME}`); + const focusAnswerElement = focusBlockData.block.closest(`.${EDITOR_ANSWER_CLASS_NAME}`); + + if (!anchorAnswerElement || !focusAnswerElement) return; + + const anchorAnswerData = getAnswerElementInfo(anchorAnswerElement); + const focusAnswerData = getAnswerElementInfo(focusAnswerElement); + + if (!anchorAnswerData || !focusAnswerData) return; + + const isSameAnswer = anchorAnswerData.id === focusAnswerData.id; + // 드래그 방향 계산 + const sortedAnswerData = [anchorAnswerData, focusAnswerData].sort((a, b) => a.index - b.index); + const isForwardDragAnswer = sortedAnswerData[0].id === anchorAnswerData.id; + + const startAnswer = isForwardDragAnswer + ? { ...anchorAnswerData, lineIndex: Number(anchorBlockData.index), offset: anchorOffset } + : { ...focusAnswerData, lineIndex: Number(focusBlockData.index), offset: focusOffset }; + + const endAnswer = isForwardDragAnswer + ? { ...focusAnswerData, lineIndex: Number(focusBlockData.index), offset: focusOffset - 1 } + : { ...anchorAnswerData, lineIndex: Number(anchorBlockData.index), offset: anchorOffset - 1 }; + + return { + isSameAnswer, + startAnswer, + endAnswer, + isForwardDragAnswer, + }; +}; + +/** + * anchorNode, focusNode가 있는 block 정보를 찾는 함수 + * @param selection + * @returns + */ +export const findSelectedElementInfo = (selection: Selection) => { + const { anchorNode, focusNode, anchorOffset, focusOffset } = selection; + const anchorBlock = anchorNode?.parentElement?.closest(`.${EDITOR_LINE_CLASS_NAME}`); + const focusBlock = focusNode?.parentElement?.closest(`.${EDITOR_LINE_CLASS_NAME}`); + + if (!anchorBlock || !focusBlock) return; + + const anchorBlockIndex = Number(anchorBlock.getAttribute('data-index') || '-1'); + const focusBlockIndex = Number(focusBlock.getAttribute('data-index') || '-1'); + + const answerInfo = getAnswerInfo({ + anchorBlockData: { block: anchorBlock, index: anchorBlockIndex }, + focusBlockData: { block: focusBlock, index: focusBlockIndex }, + anchorOffset, + focusOffset, + }); + + return { + anchorBlock, + anchorBlockIndex, + focusBlock, + focusBlockIndex, + ...answerInfo, + }; +}; + +export type SelectedBlockInfo = Exclude, undefined>; + +export const calculateStartAndEndBlock = ({ + anchorBlock, + anchorBlockIndex, + focusBlock, + focusBlockIndex, +}: SelectedBlockInfo) => { + const startBlockIndex = Math.min(anchorBlockIndex, focusBlockIndex); + const endBlockIndex = Math.max(anchorBlockIndex, focusBlockIndex); + const startBlock = startBlockIndex === anchorBlockIndex ? anchorBlock : focusBlock; + const endBlock = startBlockIndex === anchorBlockIndex ? focusBlock : anchorBlock; + + return { + startBlock, + startBlockIndex, + endBlock, + endBlockIndex, + }; +}; + +interface CalculateDragDirectionParams { + selection: Selection; + startBlockIndex: number; + endBlockIndex: number; + anchorBlockIndex: number; + isSameAnswer: boolean; + isForwardDragAnswer: boolean; +} + +export const calculateDragDirection = ({ + selection, + startBlockIndex, + endBlockIndex, + anchorBlockIndex, + isSameAnswer, + isForwardDragAnswer, +}: CalculateDragDirectionParams) => { + const { anchorOffset, focusOffset } = selection; + const minOffset = Math.min(anchorOffset, focusOffset); + + if (isSameAnswer) { + const isForwardDrag = + startBlockIndex === endBlockIndex ? minOffset === anchorOffset : startBlockIndex === anchorBlockIndex; + + return isForwardDrag; + } + + return isForwardDragAnswer; +}; + +/** + * 하이라이트 추가/삭제에 필요한 selection 관련 정보를 반환하는 함수 + * @returns + */ +export const findSelectionInfo = () => { + const selection = document.getSelection(); + if (!selection || selection.isCollapsed) return; + + const selectedElementInfo = findSelectedElementInfo(selection); + if (!selectedElementInfo) return; + const { isSameAnswer } = selectedElementInfo; + const { startBlock, startBlockIndex, endBlock, endBlockIndex } = calculateStartAndEndBlock(selectedElementInfo); + + const isForwardDrag = calculateDragDirection({ + selection, + startBlockIndex, + endBlockIndex, + anchorBlockIndex: selectedElementInfo.anchorBlockIndex, + isSameAnswer: !!isSameAnswer, + isForwardDragAnswer: !!selectedElementInfo.isForwardDragAnswer, + }); + + const isOnlyOneSelectedBlock = startBlockIndex === endBlockIndex; + + return { + selection, + startBlock, + endBlock, + startBlockIndex, + endBlockIndex, + isForwardDrag, + isOnlyOneSelectedBlock, + ...selectedElementInfo, + }; +}; + +export type EditorSelectionInfo = Exclude, undefined>; + +export const getStartBlockOffset = (infoForOffset: EditorSelectionInfo, block: EditorLine) => { + const { isForwardDrag, startBlock, selection, isOnlyOneSelectedBlock } = infoForOffset; + const { anchorNode, focusNode, anchorOffset, focusOffset } = selection; + + const startIndex = calculateOffsetInBlock({ + selectionTargetNode: isForwardDrag ? anchorNode : focusNode, + selectionTargetOffset: isForwardDrag ? anchorOffset : focusOffset, + blockElement: startBlock, + }); + // NOTE: endIndex에 -1하는 이유 : 끝나는 포커스위치의 offset이 글자 index보다 1큼 + const endIndex = isOnlyOneSelectedBlock + ? calculateOffsetInBlock({ + selectionTargetNode: isForwardDrag ? focusNode : anchorNode, + selectionTargetOffset: isForwardDrag ? focusOffset - 1 : anchorOffset - 1, + blockElement: startBlock, + }) + : block.text.length; + + return { startIndex, endIndex }; +}; + +export const getEndBlockOffset = (infoForOffset: EditorSelectionInfo) => { + const { isForwardDrag, endBlock, selection } = infoForOffset; + const { anchorNode, anchorOffset, focusNode, focusOffset } = selection; + + const endIndex = calculateOffsetInBlock({ + selectionTargetNode: isForwardDrag ? focusNode : anchorNode, + selectionTargetOffset: isForwardDrag ? focusOffset - 1 : anchorOffset - 1, + blockElement: endBlock, + }); + + return endIndex; +}; + +export const removeSelection = () => document.getSelection()?.removeAllRanges(); diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts index 924daa2cd..722d59bb5 100644 --- a/frontend/src/utils/index.ts +++ b/frontend/src/utils/index.ts @@ -6,3 +6,4 @@ export { default as hasFinalConsonant } from './hasFinalConsonant'; export { default as substituteString } from './substituteString'; export { default as calculateParticle } from './calculateParticle'; export * from './media'; +export * from './highlight/index'; From 551f2a868c2be50279c5f19a67facd69fd4455fe Mon Sep 17 00:00:00 2001 From: ImxYJL <111052302+ImxYJL@users.noreply.github.com> Date: Thu, 10 Oct 2024 17:35:57 +0900 Subject: [PATCH 07/49] =?UTF-8?q?[FE]=20feat:=20=EB=B0=9B=EC=9D=80=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EC=A0=95=EB=B3=B4=EB=A5=BC=20=EA=B0=80?= =?UTF-8?q?=EC=A0=B8=EC=98=A4=EB=8A=94=20API=20=EC=97=B0=EB=8F=99=20(#816)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 받은 리뷰 정보 모킹 데이터 생성 * feat: 받은 리뷰 정보를 가져오는 API 엔드포인트 추가 * feat: 받은 리뷰 정보 API를 호출하는 함수 작성 * feat: 받은 리뷰 정보 API를 사용하는 리액트 쿼리 훅 추가 * refactor: 받은 리뷰 정보 API 스펙에 맞춰 코드 수정 * feat: ReviewInfoData 타입 지정 * feat: 받은 리뷰 정보를 가져오는 API 요청에 대한 모킹 핸들러 작성 --- frontend/src/apis/endpoints.ts | 1 + frontend/src/apis/review.ts | 20 ++++++++- frontend/src/components/ReviewCard/index.tsx | 1 - .../components/ReviewInfoSection/index.tsx | 6 +-- .../ReviewDisplayLayout/hooks/index.ts | 2 + .../index.ts} | 0 .../hooks/useReviewInfoData/index.tsx | 21 +++++++++ .../layouts/ReviewDisplayLayout/index.tsx | 19 ++++---- frontend/src/constants/queryKey.ts | 2 +- frontend/src/mocks/handlers/review.ts | 44 ++++++++++++++----- frontend/src/mocks/mockData/index.ts | 1 + frontend/src/mocks/mockData/reviewInfoData.ts | 5 +++ .../src/pages/ReviewCollectionPage/index.tsx | 2 +- .../components/PageContents/index.tsx | 4 +- frontend/src/types/review.ts | 6 +++ 15 files changed, 102 insertions(+), 32 deletions(-) create mode 100644 frontend/src/components/layouts/ReviewDisplayLayout/hooks/index.ts rename frontend/src/components/layouts/ReviewDisplayLayout/hooks/{useReviewDisplayLayoutOptions.ts => useReviewDisplayLayoutOptions/index.ts} (100%) create mode 100644 frontend/src/components/layouts/ReviewDisplayLayout/hooks/useReviewInfoData/index.tsx create mode 100644 frontend/src/mocks/mockData/reviewInfoData.ts diff --git a/frontend/src/apis/endpoints.ts b/frontend/src/apis/endpoints.ts index 5470c788a..6c85d99dd 100644 --- a/frontend/src/apis/endpoints.ts +++ b/frontend/src/apis/endpoints.ts @@ -55,6 +55,7 @@ export const REVIEW_GROUP_DATA_API_URL = `${serverUrl}/${VERSION2}/${REVIEW_GROU const endPoint = { postingReview: `${serverUrl}/${VERSION2}/reviews`, + gettingReviewInfoData: `${serverUrl}/${VERSION2}/reviews/summary`, gettingDetailedReview: (reviewId: number) => `${DETAILED_REVIEW_API_URL}/${reviewId}`, gettingDataToWriteReview: (reviewRequestCode: string) => `${REVIEW_WRITING_API_URL}/${REVIEW_WRITING_API_PARAMS.queryString.write}?${REVIEW_WRITING_API_PARAMS.queryString.reviewRequestCode}=${reviewRequestCode}`, diff --git a/frontend/src/apis/review.ts b/frontend/src/apis/review.ts index 825f70283..df72b4709 100644 --- a/frontend/src/apis/review.ts +++ b/frontend/src/apis/review.ts @@ -1,4 +1,4 @@ -import { DetailReviewData, ReviewList, ReviewWritingFormResult, ReviewWritingFormData } from '@/types'; +import { DetailReviewData, ReviewList, ReviewWritingFormResult, ReviewWritingFormData, ReviewInfoData } from '@/types'; import createApiErrorMessage from './apiErrorMessageCreator'; import endPoint from './endpoints'; @@ -32,6 +32,24 @@ export const postReviewApi = async (formResult: ReviewWritingFormResult) => { return; }; +// 받은 리뷰들에 대한 정보(프로젝트 이름, 리뷰이, 받은 리뷰 개수) +export const getReviewInfoDataApi = async () => { + const response = await fetch(endPoint.gettingReviewInfoData, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + }); + + if (!response.ok) { + throw new Error(createApiErrorMessage(response.status)); + } + + const data = await response.json(); + return data as ReviewInfoData; +}; + interface GetDetailedReviewApi { reviewId: number; } diff --git a/frontend/src/components/ReviewCard/index.tsx b/frontend/src/components/ReviewCard/index.tsx index f820a55a4..bcb71a803 100644 --- a/frontend/src/components/ReviewCard/index.tsx +++ b/frontend/src/components/ReviewCard/index.tsx @@ -3,7 +3,6 @@ import { Category } from '@/types'; import * as S from './styles'; interface ReviewCardProps { - projectName: string; createdAt: string; contentPreview: string; categories: Category[]; diff --git a/frontend/src/components/layouts/ReviewDisplayLayout/components/ReviewInfoSection/index.tsx b/frontend/src/components/layouts/ReviewDisplayLayout/components/ReviewInfoSection/index.tsx index d1bf463bf..1a8ab689a 100644 --- a/frontend/src/components/layouts/ReviewDisplayLayout/components/ReviewInfoSection/index.tsx +++ b/frontend/src/components/layouts/ReviewDisplayLayout/components/ReviewInfoSection/index.tsx @@ -6,10 +6,10 @@ export interface ReviewInfoSectionProps { revieweeName: string; isReviewList: boolean; projectName: string; - reviewCount?: number; + totalReviewCount?: number; } -const ReviewInfoSection = ({ projectName, revieweeName, reviewCount, isReviewList }: ReviewInfoSectionProps) => { +const ReviewInfoSection = ({ projectName, revieweeName, totalReviewCount, isReviewList }: ReviewInfoSectionProps) => { const revieweeNameWithParticle = calculateParticle({ target: revieweeName, particles: { withFinalConsonant: '이', withoutFinalConsonant: '가' }, @@ -17,7 +17,7 @@ const ReviewInfoSection = ({ projectName, revieweeName, reviewCount, isReviewLis const getReviewInfoMessage = () => { return isReviewList - ? `${revieweeNameWithParticle} 받은 ${reviewCount}개의 리뷰 목록이에요` + ? `${revieweeNameWithParticle} 받은 ${totalReviewCount}개의 리뷰 목록이에요` : `${revieweeNameWithParticle} 받은 리뷰를 질문별로 모아봤어요`; }; diff --git a/frontend/src/components/layouts/ReviewDisplayLayout/hooks/index.ts b/frontend/src/components/layouts/ReviewDisplayLayout/hooks/index.ts new file mode 100644 index 000000000..e5198fb52 --- /dev/null +++ b/frontend/src/components/layouts/ReviewDisplayLayout/hooks/index.ts @@ -0,0 +1,2 @@ +export { default as useReviewDisplayLayoutOptions } from './useReviewDisplayLayoutOptions/index'; +export { default as useReviewInfoData } from './useReviewInfoData/index'; diff --git a/frontend/src/components/layouts/ReviewDisplayLayout/hooks/useReviewDisplayLayoutOptions.ts b/frontend/src/components/layouts/ReviewDisplayLayout/hooks/useReviewDisplayLayoutOptions/index.ts similarity index 100% rename from frontend/src/components/layouts/ReviewDisplayLayout/hooks/useReviewDisplayLayoutOptions.ts rename to frontend/src/components/layouts/ReviewDisplayLayout/hooks/useReviewDisplayLayoutOptions/index.ts diff --git a/frontend/src/components/layouts/ReviewDisplayLayout/hooks/useReviewInfoData/index.tsx b/frontend/src/components/layouts/ReviewDisplayLayout/hooks/useReviewInfoData/index.tsx new file mode 100644 index 000000000..b48779a27 --- /dev/null +++ b/frontend/src/components/layouts/ReviewDisplayLayout/hooks/useReviewInfoData/index.tsx @@ -0,0 +1,21 @@ +import { useSuspenseQuery } from '@tanstack/react-query'; + +import { getReviewInfoDataApi } from '@/apis/review'; +import { REVIEW_QUERY_KEY } from '@/constants'; +import { ReviewInfoData } from '@/types'; + +const useReviewInfoData = () => { + const fetchReviewInfoData = async () => { + return await getReviewInfoDataApi(); + }; + + const { data } = useSuspenseQuery({ + queryKey: [REVIEW_QUERY_KEY.reviewInfoData], + queryFn: () => fetchReviewInfoData(), + staleTime: 60 * 60 * 1000, + }); + + return data; +}; + +export default useReviewInfoData; diff --git a/frontend/src/components/layouts/ReviewDisplayLayout/index.tsx b/frontend/src/components/layouts/ReviewDisplayLayout/index.tsx index 8c22cfbfc..73545864a 100644 --- a/frontend/src/components/layouts/ReviewDisplayLayout/index.tsx +++ b/frontend/src/components/layouts/ReviewDisplayLayout/index.tsx @@ -1,18 +1,17 @@ import { TopButton, OptionSwitch } from '@/components/common'; import { EssentialPropsWithChildren } from '@/types'; -import ReviewInfoSection, { ReviewInfoSectionProps } from './components/ReviewInfoSection'; -import useReviewDisplayLayoutOptions from './hooks/useReviewDisplayLayoutOptions'; +import ReviewInfoSection from './components/ReviewInfoSection'; +import { useReviewInfoData, useReviewDisplayLayoutOptions } from './hooks'; import * as S from './styles'; -const ReviewDisplayLayout = ({ - revieweeName, - projectName, - reviewCount, - isReviewList, - children, -}: EssentialPropsWithChildren) => { +interface ReviewDisplayLayoutProps { + isReviewList: boolean; +} + +const ReviewDisplayLayout = ({ isReviewList, children }: EssentialPropsWithChildren) => { const reviewDisplayLayoutOptions = useReviewDisplayLayoutOptions(); + const { revieweeName, projectName, totalReviewCount } = useReviewInfoData(); return ( @@ -20,7 +19,7 @@ const ReviewDisplayLayout = ({ diff --git a/frontend/src/constants/queryKey.ts b/frontend/src/constants/queryKey.ts index e89c803dc..f4f89e6fb 100644 --- a/frontend/src/constants/queryKey.ts +++ b/frontend/src/constants/queryKey.ts @@ -1,9 +1,9 @@ -// TODO: 내용이 배열이 아니므로 단수형으로 수정하기 export const REVIEW_QUERY_KEY = { detailedReview: 'detailedReview', reviews: 'reviews', writingReviewInfo: 'writingReviewInfo', postReview: 'postReview', + reviewInfoData: 'reviewInfoData', }; export const GROUP_QUERY_KEY = { diff --git a/frontend/src/mocks/handlers/review.ts b/frontend/src/mocks/handlers/review.ts index 2eb0e3002..43b6bcb2a 100644 --- a/frontend/src/mocks/handlers/review.ts +++ b/frontend/src/mocks/handlers/review.ts @@ -15,6 +15,7 @@ import { REVIEW_QUESTION_DATA, REVIEW_LIST, MOCK_AUTH_TOKEN_NAME, + MOCK_REVIEW_INFO_DATA, } from '../mockData'; export const PAGE = { @@ -22,6 +23,15 @@ export const PAGE = { firstPageStartIndex: 0, }; +const getReviewInfoData = () => + http.get(endPoint.gettingReviewInfoData, async ({ cookies }) => { + if (!cookies[MOCK_AUTH_TOKEN_NAME]) { + return HttpResponse.json({ error: '인증 관련 쿠키 없음' }, { status: 401 }); + } + + return HttpResponse.json(MOCK_REVIEW_INFO_DATA); + }); + const getDetailedReview = () => http.get(new RegExp(`^${DETAILED_REVIEW_API_URL}/\\d+$`), async ({ request, cookies }) => { // authToken 쿠키 확인 @@ -43,17 +53,21 @@ const getDetailedReview = () => }); const getDataToWriteReview = () => - http.get(new RegExp(`^${REVIEW_WRITING_API_URL}`), async ({ request }) => { - //요청 url에서 reviewId, memberId 추출 - const url = new URL(request.url); - const urlRequestCode = url.searchParams.get(REVIEW_WRITING_API_PARAMS.queryString.reviewRequestCode); - - if (REVIEW_REQUEST_CODE === urlRequestCode) { - return HttpResponse.json(REVIEW_QUESTION_DATA); - } - return HttpResponse.json({ error: '잘못된 리뷰 작성 데이터 요청' }, { status: 404 }); - }); - + http.get( + new RegExp(`^${REVIEW_WRITING_API_URL}/${REVIEW_WRITING_API_PARAMS.queryString.write}`), + async ({ request }) => { + //요청 url에서 reviewId, memberId 추출 + const url = new URL(request.url); + const urlRequestCode = url.searchParams.get(REVIEW_WRITING_API_PARAMS.queryString.reviewRequestCode); + + if (REVIEW_REQUEST_CODE === urlRequestCode) { + return HttpResponse.json(REVIEW_QUESTION_DATA); + } + return HttpResponse.json({ error: '잘못된 리뷰 작성 데이터 요청' }, { status: 404 }); + }, + ); + +// TODO: 추후 getReviewList API에서 리뷰 정보(이름, 개수...)를 내려주지 않는 경우 핸들러도 수정 필요 const getReviewList = (lastReviewId: number | null, size: number) => { return http.get(endPoint.gettingReviewList(lastReviewId, size), async ({ request, cookies }) => { // authToken 쿠키 확인 @@ -90,6 +104,12 @@ const postReview = () => return HttpResponse.json({ message: 'post 성공' }, { status: 201 }); }); -const reviewHandler = [getDetailedReview(), getReviewList(null, 10), getDataToWriteReview(), postReview()]; +const reviewHandler = [ + getDetailedReview(), + getReviewList(null, 10), + getDataToWriteReview(), + postReview(), + getReviewInfoData(), +]; export default reviewHandler; diff --git a/frontend/src/mocks/mockData/index.ts b/frontend/src/mocks/mockData/index.ts index 930b9a081..1eb86e462 100644 --- a/frontend/src/mocks/mockData/index.ts +++ b/frontend/src/mocks/mockData/index.ts @@ -3,3 +3,4 @@ export * from './group'; export * from './reviewListMockData'; export * from './reviewWriting/reviewFormResultData'; export * from './reviewWriting/reviewQuestionData'; +export * from './reviewInfoData'; diff --git a/frontend/src/mocks/mockData/reviewInfoData.ts b/frontend/src/mocks/mockData/reviewInfoData.ts new file mode 100644 index 000000000..4078b9156 --- /dev/null +++ b/frontend/src/mocks/mockData/reviewInfoData.ts @@ -0,0 +1,5 @@ +export const MOCK_REVIEW_INFO_DATA = { + projectName: '리뷰미', + revieweeName: '산초', + totalReviewCount: 500, +}; diff --git a/frontend/src/pages/ReviewCollectionPage/index.tsx b/frontend/src/pages/ReviewCollectionPage/index.tsx index 3bd098ace..a4fc17672 100644 --- a/frontend/src/pages/ReviewCollectionPage/index.tsx +++ b/frontend/src/pages/ReviewCollectionPage/index.tsx @@ -21,7 +21,7 @@ const ReviewCollectionPage = () => { return ( - + { navigate(`/${ROUTE.detailedReview}/${reviewRequestCode}/${id}`); }; - const { projectName, revieweeName } = data.pages[0]; const isLastPage = data.pages[data.pages.length - 1].isLastPage; const reviews = data.pages.flatMap((page) => page.reviews); @@ -36,7 +35,7 @@ const PageContents = () => { return ( isSuccess && ( - + {reviews.length === 0 ? ( ) : ( @@ -46,7 +45,6 @@ const PageContents = () => { return ( Date: Thu, 10 Oct 2024 17:48:47 +0900 Subject: [PATCH 08/49] =?UTF-8?q?[FE]=20=EB=A6=AC=EB=B7=B0=20=EB=AA=A8?= =?UTF-8?q?=EC=95=84=EB=B3=B4=EA=B8=B0=20=ED=8E=98=EC=9D=B4=EC=A7=80=20API?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EC=97=B0=EB=8F=99=20(#817)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 섹션 리스트 요청 엔드포인트 및 호출 로직 작성 * feat: react-query를 사용하여 섹션 리스트 요청 로직 구현 및 msw 모킹 * feat: 모아보기 리뷰 엔드포인트 및 호출 로직 작성 * fix: Dropdown 컴포넌트가 상태로 DropdownItem을 가지도록 수정 * feat: react-query를 사용하여 섹션별 모아보기 요청 로직 구현 및 msw 모킹 * fix: msw handler에 쿠키 확인하는 코드 추가 * feat: 형광펜 에디터 적용 및 인터페이스 수정 * chore: 불필요한 코드 제거 --- frontend/src/apis/endpoints.ts | 2 + frontend/src/apis/review.ts | 48 ++++++++++++++++++- .../src/components/common/Dropdown/index.tsx | 14 +++--- frontend/src/constants/queryKey.ts | 2 + frontend/src/hooks/useDropdown.ts | 6 ++- frontend/src/mocks/handlers/review.ts | 21 +++++++- .../src/mocks/mockData/reviewCollection.ts | 10 ++++ .../hooks/useGetGroupedReviews.ts | 26 ++++++++++ .../hooks/useGetSectionList.ts | 22 +++++++++ .../src/pages/ReviewCollectionPage/index.tsx | 31 ++++++------ frontend/src/types/review.ts | 5 +- 11 files changed, 161 insertions(+), 26 deletions(-) create mode 100644 frontend/src/pages/ReviewCollectionPage/hooks/useGetGroupedReviews.ts create mode 100644 frontend/src/pages/ReviewCollectionPage/hooks/useGetSectionList.ts diff --git a/frontend/src/apis/endpoints.ts b/frontend/src/apis/endpoints.ts index 6c85d99dd..234e90667 100644 --- a/frontend/src/apis/endpoints.ts +++ b/frontend/src/apis/endpoints.ts @@ -69,6 +69,8 @@ const endPoint = { checkingPassword: `${serverUrl}/${VERSION2}/${REVIEW_PASSWORD_API_PARAMS.resource}/${REVIEW_PASSWORD_API_PARAMS.queryString.check}`, gettingReviewGroupData: (reviewRequestCode: string) => `${REVIEW_GROUP_DATA_API_URL}?${REVIEW_GROUP_DATA_API_PARAMS.queryString.reviewRequestCode}=${reviewRequestCode}`, + gettingSectionList: `${serverUrl}/${VERSION2}/sections`, + gettingGroupedReviews: (sectionId: number) => `${serverUrl}/${VERSION2}/reviews/gather?sectionId=${sectionId}`, postingHighlight: `${serverUrl}/${VERSION2}/highlight`, }; diff --git a/frontend/src/apis/review.ts b/frontend/src/apis/review.ts index df72b4709..6869ef348 100644 --- a/frontend/src/apis/review.ts +++ b/frontend/src/apis/review.ts @@ -1,4 +1,12 @@ -import { DetailReviewData, ReviewList, ReviewWritingFormResult, ReviewWritingFormData, ReviewInfoData } from '@/types'; +import { + DetailReviewData, + ReviewList, + ReviewWritingFormResult, + ReviewWritingFormData, + GroupedSection, + GroupedReviews, + ReviewInfoData, +} from '@/types'; import createApiErrorMessage from './apiErrorMessageCreator'; import endPoint from './endpoints'; @@ -92,3 +100,41 @@ export const getReviewListApi = async ({ lastReviewId, size }: GetReviewListApi) const data = await response.json(); return data as ReviewList; }; + +export const getSectionList = async () => { + const response = await fetch(endPoint.gettingSectionList, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + }); + + if (!response.ok) { + throw new Error(createApiErrorMessage(response.status)); + } + + const data = await response.json(); + return data as GroupedSection; +}; + +interface GetGroupedReviewsProps { + sectionId: number; +} + +export const getGroupedReviews = async ({ sectionId }: GetGroupedReviewsProps) => { + const response = await fetch(endPoint.gettingGroupedReviews(sectionId), { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + }); + + if (!response.ok) { + throw new Error(createApiErrorMessage(response.status)); + } + + const data = await response.json(); + return data as GroupedReviews; +}; diff --git a/frontend/src/components/common/Dropdown/index.tsx b/frontend/src/components/common/Dropdown/index.tsx index 8943573f9..9ae816c14 100644 --- a/frontend/src/components/common/Dropdown/index.tsx +++ b/frontend/src/components/common/Dropdown/index.tsx @@ -3,31 +3,31 @@ import useDropdown from '@/hooks/useDropdown'; import * as S from './styles'; -interface DropdownItem { +export interface DropdownItem { text: string; - value: string; + value: string | number; } interface DropdownProps { items: DropdownItem[]; - selectedItem: string; - handleSelect: (item: string) => void; + selectedItem: DropdownItem; + handleSelect: (item: DropdownItem) => void; } -const Dropdown = ({ items, selectedItem: selectedOption, handleSelect }: DropdownProps) => { +const Dropdown = ({ items, selectedItem, handleSelect }: DropdownProps) => { const { isOpened, handleDropdownButtonClick, handleOptionClick, dropdownRef } = useDropdown({ handleSelect }); return ( - {selectedOption} + {selectedItem.text} {isOpened && ( {items.map((item) => { return ( - handleOptionClick(item.value)}> + handleOptionClick(item)}> {item.text} ); diff --git a/frontend/src/constants/queryKey.ts b/frontend/src/constants/queryKey.ts index f4f89e6fb..452f55f2b 100644 --- a/frontend/src/constants/queryKey.ts +++ b/frontend/src/constants/queryKey.ts @@ -3,6 +3,8 @@ export const REVIEW_QUERY_KEY = { reviews: 'reviews', writingReviewInfo: 'writingReviewInfo', postReview: 'postReview', + sectionList: 'sectionList', + groupedReviews: 'groupedReviews', reviewInfoData: 'reviewInfoData', }; diff --git a/frontend/src/hooks/useDropdown.ts b/frontend/src/hooks/useDropdown.ts index da17270ca..f73aeaa82 100644 --- a/frontend/src/hooks/useDropdown.ts +++ b/frontend/src/hooks/useDropdown.ts @@ -1,7 +1,9 @@ import { useEffect, useRef, useState } from 'react'; +import { DropdownItem } from '@/components/common/Dropdown'; + interface UseDropdownProps { - handleSelect: (option: string) => void; + handleSelect: (option: DropdownItem) => void; } const useDropdown = ({ handleSelect }: UseDropdownProps) => { @@ -13,7 +15,7 @@ const useDropdown = ({ handleSelect }: UseDropdownProps) => { setIsOpened((prev) => !prev); }; - const handleOptionClick = (option: string) => { + const handleOptionClick = (option: DropdownItem) => { handleSelect(option); setIsOpened(false); }; diff --git a/frontend/src/mocks/handlers/review.ts b/frontend/src/mocks/handlers/review.ts index 43b6bcb2a..678b6d711 100644 --- a/frontend/src/mocks/handlers/review.ts +++ b/frontend/src/mocks/handlers/review.ts @@ -17,6 +17,7 @@ import { MOCK_AUTH_TOKEN_NAME, MOCK_REVIEW_INFO_DATA, } from '../mockData'; +import { GROUPED_REVIEWS_MOCK_DATA, GROUPED_SECTION_MOCK_DATA } from '../mockData/reviewCollection'; export const PAGE = { firstPageNumber: 1, @@ -104,12 +105,30 @@ const postReview = () => return HttpResponse.json({ message: 'post 성공' }, { status: 201 }); }); +const getSectionList = () => + http.get(endPoint.gettingSectionList, async ({ request, cookies }) => { + // authToken 쿠키 확인 + if (!cookies[MOCK_AUTH_TOKEN_NAME]) return HttpResponse.json({ error: '인증 관련 쿠키 없음' }, { status: 401 }); + + return HttpResponse.json(GROUPED_SECTION_MOCK_DATA); + }); + +const getGroupedReviews = (sectionId: number) => + http.get(endPoint.gettingGroupedReviews(sectionId), async ({ request, cookies }) => { + // authToken 쿠키 확인 + if (!cookies[MOCK_AUTH_TOKEN_NAME]) return HttpResponse.json({ error: '인증 관련 쿠키 없음' }, { status: 401 }); + + return HttpResponse.json(GROUPED_REVIEWS_MOCK_DATA); + }); + const reviewHandler = [ getDetailedReview(), getReviewList(null, 10), getDataToWriteReview(), - postReview(), + getSectionList(), + getGroupedReviews(1), getReviewInfoData(), + postReview(), ]; export default reviewHandler; diff --git a/frontend/src/mocks/mockData/reviewCollection.ts b/frontend/src/mocks/mockData/reviewCollection.ts index 94aa20786..d7ee7b730 100644 --- a/frontend/src/mocks/mockData/reviewCollection.ts +++ b/frontend/src/mocks/mockData/reviewCollection.ts @@ -23,6 +23,7 @@ export const GROUPED_REVIEWS_MOCK_DATA: GroupedReviews = { reviews: [ { question: { + id: 1, name: '커뮤니케이션, 협업 능력에서 어떤 부분이 인상 깊었는지 선택해주세요', type: 'CHECKBOX', }, @@ -39,25 +40,34 @@ export const GROUPED_REVIEWS_MOCK_DATA: GroupedReviews = { }, { question: { + id: 2, name: '위에서 선택한 사항에 대해 조금 더 자세히 설명해주세요', type: 'TEXT', }, answers: [ { + id: 1, content: '장의 시작부분은 짧고 직접적이며, 뒤따라 나올 복잡한 정보를 어떻게 해석해야 할 것인지 프레임을 짜주는 역할을 해야 한다. 그러면 아무리 긴 문장이라도 쉽게 읽힌다.', + highlights: [], }, { + id: 2, content: '고액공제건강보험과 건강저축계좌를 만들어 노동자와 고용주가 세금공제를 받을 수 있도록 하면 결과적으로 노동자의 의료보험 부담이 커진다.', + highlights: [], }, { + id: 3, content: '장의 시작부분은 짧고 직접적이며, 뒤따라 나올 복잡한 정보를 어떻게 해석해야 할 것인지 프레임을 짜주는 역할을 해야 한다. 그러면 아무리 긴 문장이라도 쉽게 읽힌다.', + highlights: [], }, { + id: 4, content: '고액공제건강보험과 건강저축계좌를 만들어 노동자와 고용주가 세금공제를 받을 수 있도록 하면 결과적으로 노동자의 의료보험 부담이 커진다.', + highlights: [], }, ], votes: null, diff --git a/frontend/src/pages/ReviewCollectionPage/hooks/useGetGroupedReviews.ts b/frontend/src/pages/ReviewCollectionPage/hooks/useGetGroupedReviews.ts new file mode 100644 index 000000000..be16a1427 --- /dev/null +++ b/frontend/src/pages/ReviewCollectionPage/hooks/useGetGroupedReviews.ts @@ -0,0 +1,26 @@ +import { useSuspenseQuery } from '@tanstack/react-query'; + +import { getGroupedReviews } from '@/apis/review'; +import { REVIEW_QUERY_KEY } from '@/constants'; +import { GroupedReviews } from '@/types'; + +interface UseGetGroupedReviewsProps { + sectionId: number; +} + +const useGetGroupedReviews = ({ sectionId }: UseGetGroupedReviewsProps) => { + const fetchGroupedReviews = async () => { + const result = await getGroupedReviews({ sectionId }); + return result; + }; + + const result = useSuspenseQuery({ + queryKey: [REVIEW_QUERY_KEY.groupedReviews, sectionId], + queryFn: () => fetchGroupedReviews(), + staleTime: 1 * 60 * 1000, + }); + + return result; +}; + +export default useGetGroupedReviews; diff --git a/frontend/src/pages/ReviewCollectionPage/hooks/useGetSectionList.ts b/frontend/src/pages/ReviewCollectionPage/hooks/useGetSectionList.ts new file mode 100644 index 000000000..1e094d068 --- /dev/null +++ b/frontend/src/pages/ReviewCollectionPage/hooks/useGetSectionList.ts @@ -0,0 +1,22 @@ +import { useSuspenseQuery } from '@tanstack/react-query'; + +import { getSectionList } from '@/apis/review'; +import { REVIEW_QUERY_KEY } from '@/constants'; +import { GroupedSection } from '@/types'; + +const useGetSectionList = () => { + const fetchSectionList = async () => { + const result = await getSectionList(); + return result; + }; + + const result = useSuspenseQuery({ + queryKey: [REVIEW_QUERY_KEY.sectionList], + queryFn: () => fetchSectionList(), + staleTime: 60 * 60 * 1000, + }); + + return result; +}; + +export default useGetSectionList; diff --git a/frontend/src/pages/ReviewCollectionPage/index.tsx b/frontend/src/pages/ReviewCollectionPage/index.tsx index a4fc17672..1c3589378 100644 --- a/frontend/src/pages/ReviewCollectionPage/index.tsx +++ b/frontend/src/pages/ReviewCollectionPage/index.tsx @@ -1,23 +1,23 @@ import { useState } from 'react'; import { Accordion, AuthAndServerErrorFallback, Dropdown, ErrorSuspenseContainer, TopButton } from '@/components'; +import { DropdownItem } from '@/components/common/Dropdown'; +import HighlightEditor from '@/components/highlight/HighlightEditor'; import ReviewDisplayLayout from '@/components/layouts/ReviewDisplayLayout'; -import { useGetReviewList } from '@/hooks'; -import { GROUPED_REVIEWS_MOCK_DATA, GROUPED_SECTION_MOCK_DATA } from '@/mocks/mockData/reviewCollection'; import DoughnutChart from './components/DoughnutChart'; +import useGetGroupedReviews from './hooks/useGetGroupedReviews'; +import useGetSectionList from './hooks/useGetSectionList'; import * as S from './styles'; const ReviewCollectionPage = () => { - // TODO: 추후 리뷰 그룹 정보를 받아오는 API로 대체 - const { data } = useGetReviewList(); - const { revieweeName, projectName } = data.pages[0]; - - // TODO: react-query 적용 및 드롭다운 아이템 선택 시 요청 - const reviewSectionList = GROUPED_SECTION_MOCK_DATA.sections.map((section) => { - return { text: section.name, value: section.name }; + const { data: reviewSectionList } = useGetSectionList(); + const dropdownSectionList = reviewSectionList.sections.map((section) => { + return { text: section.name, value: section.id }; }); - const [reviewSection, setReviewSection] = useState(reviewSectionList[0].value); + + const [selectedSection, setSelectedSection] = useState(dropdownSectionList[0]); + const { data: groupedReviews } = useGetGroupedReviews({ sectionId: selectedSection.value as number }); return ( @@ -25,19 +25,22 @@ const ReviewCollectionPage = () => { setReviewSection(item)} + items={dropdownSectionList} + selectedItem={dropdownSectionList.find((section) => section.value === selectedSection.value)!} + handleSelect={(item) => setSelectedSection(item)} /> - {GROUPED_REVIEWS_MOCK_DATA.reviews.map((review, index) => { + {groupedReviews.reviews.map((review, index) => { return ( {review.question.type === 'CHECKBOX' ? ( ) : ( + {review.answers && ( + + )} {review.answers?.map((answer, index) => { return {answer.content}; })} diff --git a/frontend/src/types/review.ts b/frontend/src/types/review.ts index 00f3a51f3..041d4d731 100644 --- a/frontend/src/types/review.ts +++ b/frontend/src/types/review.ts @@ -1,3 +1,5 @@ +import { ReviewAnswerResponseData } from './highlight'; + export interface Keyword { id: number; content: string; @@ -110,6 +112,7 @@ export interface GroupedReviews { export interface GroupedReview { question: { + id: number; name: string; type: QuestionType; }; @@ -117,7 +120,7 @@ export interface GroupedReview { * CollectedReviewAnswer[] : 주관식 질문에서 답변 모아놓은 배열 * null : 객관식 질문인 경우 */ - answers: ReviewAnswer[] | null; + answers: ReviewAnswerResponseData[] | null; /** * CollectedReviewVotes[] : 객관식 질문에서 옵션-득표수 모아놓은 배열 * null : 주관식 질문인 경우 From 1f02630b6a1df8caed205264ee532bf639926dcd Mon Sep 17 00:00:00 2001 From: Fe <64690761+chysis@users.noreply.github.com> Date: Fri, 11 Oct 2024 13:42:47 +0900 Subject: [PATCH 09/49] =?UTF-8?q?[FE]=20fix:=20=EC=84=B9=EC=85=98=EB=B3=84?= =?UTF-8?q?=20=EB=8B=B5=EB=B3=80=20=EB=AA=A8=EC=95=84=EB=B3=B4=EA=B8=B0?= =?UTF-8?q?=EC=9D=98=20=EC=A4=91=EB=B3=B5=20=EC=B6=9C=EB=A0=A5=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=ED=95=B4=EA=B2=B0=20=EB=B0=8F=20=EB=A6=AC=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=ED=98=95=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EC=B6=9C?= =?UTF-8?q?=EB=A0=A5=20(#821)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 받은 리뷰가 중복되어 표시되는 오류 수정 * design: 형광펜 에디터가 답변을 list 형태로 출력하도록 설정 * design: 답변 간 간격 설정 --- .../src/components/highlight/EditorLineBlock/index.tsx | 6 ++++-- .../src/components/highlight/EditorLineBlock/style.ts | 8 ++++++++ .../src/components/highlight/HighlightEditor/index.tsx | 4 ++-- .../src/components/highlight/HighlightEditor/style.ts | 10 ++++++++++ frontend/src/pages/ReviewCollectionPage/index.tsx | 3 --- 5 files changed, 24 insertions(+), 7 deletions(-) create mode 100644 frontend/src/components/highlight/EditorLineBlock/style.ts diff --git a/frontend/src/components/highlight/EditorLineBlock/index.tsx b/frontend/src/components/highlight/EditorLineBlock/index.tsx index cf6c533a6..5807e6fe4 100644 --- a/frontend/src/components/highlight/EditorLineBlock/index.tsx +++ b/frontend/src/components/highlight/EditorLineBlock/index.tsx @@ -3,6 +3,8 @@ import { EditorLine, HighlightRange } from '@/types'; import Syntax from '../Syntax'; +import * as S from './style'; + interface EditorLineBlockProps { line: EditorLine; lineIndex: number; @@ -60,9 +62,9 @@ const EditorLineBlock = ({ line, lineIndex }: EditorLineBlockProps) => { }; return ( -

+ {renderSentenceList()} -

+ ); }; diff --git a/frontend/src/components/highlight/EditorLineBlock/style.ts b/frontend/src/components/highlight/EditorLineBlock/style.ts new file mode 100644 index 000000000..9774d0ecb --- /dev/null +++ b/frontend/src/components/highlight/EditorLineBlock/style.ts @@ -0,0 +1,8 @@ +import styled from '@emotion/styled'; + +export const EditorLineBlock = styled.p` + display: list-item; + margin-left: 3rem; + text-indent: -2.2rem; + list-style-type: disc; +`; diff --git a/frontend/src/components/highlight/HighlightEditor/index.tsx b/frontend/src/components/highlight/HighlightEditor/index.tsx index acbf6d298..92b6364a6 100644 --- a/frontend/src/components/highlight/HighlightEditor/index.tsx +++ b/frontend/src/components/highlight/HighlightEditor/index.tsx @@ -92,7 +92,7 @@ const HighlightEditor = ({ questionId, answerList }: HighlightEditorProps) => { }, [isEditable]); return ( -
+ @@ -120,7 +120,7 @@ const HighlightEditor = ({ questionId, answerList }: HighlightEditorProps) => { {isEditable && removalTarget && removerPosition && ( )} -
+ ); }; diff --git a/frontend/src/components/highlight/HighlightEditor/style.ts b/frontend/src/components/highlight/HighlightEditor/style.ts index f1069f686..c9b91d819 100644 --- a/frontend/src/components/highlight/HighlightEditor/style.ts +++ b/frontend/src/components/highlight/HighlightEditor/style.ts @@ -1,5 +1,15 @@ import styled from '@emotion/styled'; +export const HighlightEditorContainer = styled.div` + position: relative; + + display: flex; + flex-direction: column; + gap: 1rem; + + padding: 1rem; +`; + export const SwitchButtonWrapper = styled.div` display: flex; justify-content: end; diff --git a/frontend/src/pages/ReviewCollectionPage/index.tsx b/frontend/src/pages/ReviewCollectionPage/index.tsx index 1c3589378..8a93d5a08 100644 --- a/frontend/src/pages/ReviewCollectionPage/index.tsx +++ b/frontend/src/pages/ReviewCollectionPage/index.tsx @@ -41,9 +41,6 @@ const ReviewCollectionPage = () => { {review.answers && ( )} - {review.answers?.map((answer, index) => { - return {answer.content}; - })}
)}
From 0b2f228bf969ac98ae24cdc80c68009d71de86e1 Mon Sep 17 00:00:00 2001 From: badahertz52 Date: Fri, 11 Oct 2024 13:45:06 +0900 Subject: [PATCH 10/49] =?UTF-8?q?feat:=20=ED=98=95=EA=B4=91=ED=8E=9C=20?= =?UTF-8?q?=EB=AA=A8=EB=93=9C=20on/off=20=ED=91=9C=EC=8B=9C=20=EB=A9=94?= =?UTF-8?q?=EC=84=B8=EC=A7=80=20=EC=B6=94=EA=B0=80=20(#823)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/assets/grayHighlighter.svg | 9 +++++++++ frontend/src/assets/primaryHighlighter.svg | 9 +++++++++ .../highlight/EditSwitchButton/style.ts | 7 ++++--- .../highlight/HighlightEditor/index.tsx | 17 +++++++++++++++++ .../highlight/HighlightEditor/style.ts | 13 +++++++++++++ 5 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 frontend/src/assets/grayHighlighter.svg create mode 100644 frontend/src/assets/primaryHighlighter.svg diff --git a/frontend/src/assets/grayHighlighter.svg b/frontend/src/assets/grayHighlighter.svg new file mode 100644 index 000000000..251127658 --- /dev/null +++ b/frontend/src/assets/grayHighlighter.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/src/assets/primaryHighlighter.svg b/frontend/src/assets/primaryHighlighter.svg new file mode 100644 index 000000000..23faf8946 --- /dev/null +++ b/frontend/src/assets/primaryHighlighter.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/src/components/highlight/EditSwitchButton/style.ts b/frontend/src/components/highlight/EditSwitchButton/style.ts index 160647eb1..7e1dcc9da 100644 --- a/frontend/src/components/highlight/EditSwitchButton/style.ts +++ b/frontend/src/components/highlight/EditSwitchButton/style.ts @@ -6,7 +6,8 @@ interface EditorSwitchProps { export const EditSwitchButton = styled.button` cursor: pointer; - width: 4rem; + width: 3.5rem; + height: 2rem; padding: 0.5rem; background-color: ${({ theme, $isEditable }) => ($isEditable ? theme.colors.primary : theme.colors.gray)}; @@ -18,8 +19,8 @@ export const EditSwitchButton = styled.button` export const Circle = styled.div` transform: translateX(${({ $isEditable }) => ($isEditable ? 0 : '1.5rem')}); - width: 1.5rem; - height: 1.5rem; + width: 1rem; + height: 1rem; background-color: ${({ theme }) => theme.colors.white}; border-radius: 50%; diff --git a/frontend/src/components/highlight/HighlightEditor/index.tsx b/frontend/src/components/highlight/HighlightEditor/index.tsx index 92b6364a6..bafcdf538 100644 --- a/frontend/src/components/highlight/HighlightEditor/index.tsx +++ b/frontend/src/components/highlight/HighlightEditor/index.tsx @@ -1,5 +1,7 @@ import { useEffect, useRef, useState } from 'react'; +import GrayHighlighterIcon from '@/assets/grayHighlighter.svg'; +import PrimaryHighlighterIcon from '@/assets/primaryHighlighter.svg'; import { EDITOR_ANSWER_CLASS_NAME, EDITOR_LINE_CLASS_NAME, @@ -22,6 +24,16 @@ import HighlightToggleButtonContainer from '../HighlightToggleButtonContainer'; import * as S from './style'; +const MODE_ICON = { + on: { + icon: PrimaryHighlighterIcon, + alt: '형광펜 기능 켜짐', + }, + off: { + icon: GrayHighlighterIcon, + alt: '형광펜 기능 꺼짐', + }, +}; interface HighlightEditorProps { questionId: number; answerList: ReviewAnswerResponseData[]; @@ -94,6 +106,11 @@ const HighlightEditor = ({ questionId, answerList }: HighlightEditorProps) => { return ( + 형광펜 + {[...editorAnswerMap.values()].map(({ answerId, answerIndex, lineList }) => ( diff --git a/frontend/src/components/highlight/HighlightEditor/style.ts b/frontend/src/components/highlight/HighlightEditor/style.ts index c9b91d819..f216fc60c 100644 --- a/frontend/src/components/highlight/HighlightEditor/style.ts +++ b/frontend/src/components/highlight/HighlightEditor/style.ts @@ -12,7 +12,20 @@ export const HighlightEditorContainer = styled.div` export const SwitchButtonWrapper = styled.div` display: flex; + gap: 0.5rem; + align-items: center; justify-content: end; + width: 100%; margin-bottom: 1rem; `; + +export const SwitchModIcon = styled.img` + width: 1.6rem; + height: 1.6rem; +`; + +export const HighlightText = styled.span<{ $isEditable: boolean }>` + display: inline-block; + color: ${({ $isEditable, theme }) => ($isEditable ? theme.colors.primary : theme.colors.gray)}; +`; From 41d8c66dcc0c268fef6c2c45b40b4dfdac91af2b Mon Sep 17 00:00:00 2001 From: Kimprodp Date: Fri, 11 Oct 2024 15:42:53 +0900 Subject: [PATCH 11/49] =?UTF-8?q?[BE]=20feat:=20=EC=84=B9=EC=85=98=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=EC=A1=B0=ED=9A=8C=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#801)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 섹션 이름 목록 응답 기능 구현 * test: 섹션 이름 목록 응답 기능 테스트 작성 * refactor: section의 questionIds eager 로딩 변경 * test: 섹션 이름 목록 테스트 시, 이름으로 검증할 수 있도록 변경 * test: 섹션 이름 목록 반환 테스트 시, 검증하려는 섹션명을 따로 빼는 것으로 변경 --- .../template/service/SectionService.java | 19 ++++- .../template/service/SectionServiceTest.java | 73 +++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 backend/src/test/java/reviewme/template/service/SectionServiceTest.java diff --git a/backend/src/main/java/reviewme/template/service/SectionService.java b/backend/src/main/java/reviewme/template/service/SectionService.java index a9c5fb4fa..90bc7d1f7 100644 --- a/backend/src/main/java/reviewme/template/service/SectionService.java +++ b/backend/src/main/java/reviewme/template/service/SectionService.java @@ -1,16 +1,33 @@ package reviewme.template.service; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import reviewme.review.service.exception.ReviewGroupNotFoundByReviewRequestCodeException; +import reviewme.reviewgroup.domain.ReviewGroup; +import reviewme.reviewgroup.repository.ReviewGroupRepository; +import reviewme.template.repository.SectionRepository; +import reviewme.template.service.dto.response.SectionNameResponse; import reviewme.template.service.dto.response.SectionNamesResponse; @Service @RequiredArgsConstructor public class SectionService { + private final ReviewGroupRepository reviewGroupRepository; + private final SectionRepository sectionRepository; + @Transactional(readOnly = true) public SectionNamesResponse getSectionNames(String reviewRequestCode) { - return null; + ReviewGroup reviewGroup = reviewGroupRepository.findByReviewRequestCode(reviewRequestCode) + .orElseThrow(() -> new ReviewGroupNotFoundByReviewRequestCodeException(reviewRequestCode)); + + List sectionNameResponses = sectionRepository.findAllByTemplateId(reviewGroup.getTemplateId()) + .stream() + .map(section -> new SectionNameResponse(section.getId(), section.getSectionName())) + .toList(); + + return new SectionNamesResponse(sectionNameResponses); } } diff --git a/backend/src/test/java/reviewme/template/service/SectionServiceTest.java b/backend/src/test/java/reviewme/template/service/SectionServiceTest.java new file mode 100644 index 000000000..9ce12b2a5 --- /dev/null +++ b/backend/src/test/java/reviewme/template/service/SectionServiceTest.java @@ -0,0 +1,73 @@ +package reviewme.template.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static reviewme.fixture.ReviewGroupFixture.리뷰_그룹; +import static reviewme.fixture.TemplateFixture.템플릿; + +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import reviewme.review.service.exception.ReviewGroupNotFoundByReviewRequestCodeException; +import reviewme.reviewgroup.domain.ReviewGroup; +import reviewme.reviewgroup.repository.ReviewGroupRepository; +import reviewme.support.ServiceTest; +import reviewme.template.domain.Section; +import reviewme.template.domain.VisibleType; +import reviewme.template.repository.SectionRepository; +import reviewme.template.repository.TemplateRepository; +import reviewme.template.service.dto.response.SectionNameResponse; +import reviewme.template.service.dto.response.SectionNamesResponse; + +@ServiceTest +class SectionServiceTest { + + @Autowired + private SectionService sectionService; + + @Autowired + private ReviewGroupRepository reviewGroupRepository; + + @Autowired + private TemplateRepository templateRepository; + + @Autowired + private SectionRepository sectionRepository; + + @Test + void 템플릿에_있는_섹션_이름_목록을_응답한다() { + // given + String sectionName1 = "섹션1"; + String sectionName2 = "섹션2"; + String sectionName3 = "섹션3"; + + Section visibleSection1 = sectionRepository.save( + new Section(VisibleType.ALWAYS, List.of(1L), null, sectionName1, "헤더", 1)); + Section visibleSection2 = sectionRepository.save( + new Section(VisibleType.ALWAYS, List.of(2L), null, sectionName2, "헤더", 2)); + Section nonVisibleSection = sectionRepository.save( + new Section(VisibleType.CONDITIONAL, List.of(1L), 1L, sectionName3, "헤더", 3)); + templateRepository.save( + 템플릿(List.of(nonVisibleSection.getId(), visibleSection2.getId(), visibleSection1.getId()))); + + ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹()); + + // when + SectionNamesResponse actual = sectionService.getSectionNames(reviewGroup.getReviewRequestCode()); + + // then + assertThat(actual.sections()).extracting(SectionNameResponse::name) + .containsExactly(sectionName1, sectionName2, sectionName3); + } + + @Test + void 템플릿에_있는_섹션_이름_목록_조회시_리뷰_요청_코드가_존재하지_않는_경우_예외가_발생한다() { + // given + ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹()); + + // when, then + assertThatThrownBy(() -> sectionService.getSectionNames( + reviewGroup.getReviewRequestCode() + "wrong")) + .isInstanceOf(ReviewGroupNotFoundByReviewRequestCodeException.class); + } +} From 5815e4893d6467249a0bd55911d73101f21648ed Mon Sep 17 00:00:00 2001 From: Kimprodp Date: Fri, 11 Oct 2024 16:25:12 +0900 Subject: [PATCH 12/49] =?UTF-8?q?[BE]=20feat:=20=EB=B0=9B=EC=9D=80=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EC=9A=94=EC=95=BD=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?API=20=EA=B5=AC=ED=98=84=20(#796)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 리뷰 요약 조회 기능 구현 * test: 리뷰 요약 조회 기능 테스트 작성 * test: 리뷰 그룹 픽스쳐 사용하도록 변경 * test: 리뷰 요약 조회 테스트에서 list 수를 확인하도록 변경 * test: 리뷰 요약 정보 조회 테스트에 다른 그룹 추가하는 조건 설정 * refactor: Transactional 설정 * style: 개행 --- .../review/controller/ReviewController.java | 2 - .../review/repository/ReviewRepository.java | 2 + .../review/service/ReviewSummaryService.java | 22 ++++- .../service/ReviewSummaryServiceTest.java | 89 +++++++++++++++++++ 4 files changed, 112 insertions(+), 3 deletions(-) create mode 100644 backend/src/test/java/reviewme/review/service/ReviewSummaryServiceTest.java diff --git a/backend/src/main/java/reviewme/review/controller/ReviewController.java b/backend/src/main/java/reviewme/review/controller/ReviewController.java index ba237ddde..7bca618ce 100644 --- a/backend/src/main/java/reviewme/review/controller/ReviewController.java +++ b/backend/src/main/java/reviewme/review/controller/ReviewController.java @@ -26,8 +26,6 @@ @RequiredArgsConstructor public class ReviewController { - private static final String GROUP_ACCESS_CODE_HEADER = "GroupAccessCode"; - private final ReviewRegisterService reviewRegisterService; private final ReviewListLookupService reviewListLookupService; private final ReviewDetailLookupService reviewDetailLookupService; diff --git a/backend/src/main/java/reviewme/review/repository/ReviewRepository.java b/backend/src/main/java/reviewme/review/repository/ReviewRepository.java index 26aacb9bd..bde88eeac 100644 --- a/backend/src/main/java/reviewme/review/repository/ReviewRepository.java +++ b/backend/src/main/java/reviewme/review/repository/ReviewRepository.java @@ -38,4 +38,6 @@ AND CAST(r.created_at AS DATE) <= :createdDate default boolean existsOlderReviewInGroup(long reviewGroupId, long reviewId, LocalDate createdDate) { return existsOlderReviewInGroupInLong(reviewGroupId, reviewId, createdDate) > 0; } + + int countByReviewGroupId(long reviewGroupId); } diff --git a/backend/src/main/java/reviewme/review/service/ReviewSummaryService.java b/backend/src/main/java/reviewme/review/service/ReviewSummaryService.java index d556ef5c1..50cc80354 100644 --- a/backend/src/main/java/reviewme/review/service/ReviewSummaryService.java +++ b/backend/src/main/java/reviewme/review/service/ReviewSummaryService.java @@ -1,12 +1,32 @@ package reviewme.review.service; +import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import reviewme.review.repository.ReviewRepository; import reviewme.review.service.dto.response.list.ReceivedReviewsSummaryResponse; +import reviewme.review.service.exception.ReviewGroupNotFoundByReviewRequestCodeException; +import reviewme.reviewgroup.domain.ReviewGroup; +import reviewme.reviewgroup.repository.ReviewGroupRepository; @Service +@RequiredArgsConstructor public class ReviewSummaryService { + private final ReviewGroupRepository reviewGroupRepository; + private final ReviewRepository reviewRepository; + + @Transactional(readOnly = true) public ReceivedReviewsSummaryResponse getReviewSummary(String reviewRequestCode) { - return null; + ReviewGroup reviewGroup = reviewGroupRepository.findByReviewRequestCode(reviewRequestCode) + .orElseThrow(() -> new ReviewGroupNotFoundByReviewRequestCodeException(reviewRequestCode)); + + int totalReviewCount = reviewRepository.countByReviewGroupId(reviewGroup.getId()); + + return new ReceivedReviewsSummaryResponse( + reviewGroup.getProjectName(), + reviewGroup.getReviewee(), + totalReviewCount + ); } } diff --git a/backend/src/test/java/reviewme/review/service/ReviewSummaryServiceTest.java b/backend/src/test/java/reviewme/review/service/ReviewSummaryServiceTest.java new file mode 100644 index 000000000..2a6e56088 --- /dev/null +++ b/backend/src/test/java/reviewme/review/service/ReviewSummaryServiceTest.java @@ -0,0 +1,89 @@ +package reviewme.review.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; +import static reviewme.fixture.QuestionFixture.서술형_필수_질문; +import static reviewme.fixture.ReviewGroupFixture.리뷰_그룹; +import static reviewme.fixture.SectionFixture.항상_보이는_섹션; +import static reviewme.fixture.TemplateFixture.템플릿; + +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import reviewme.question.domain.Question; +import reviewme.question.repository.QuestionRepository; +import reviewme.review.domain.Review; +import reviewme.review.repository.ReviewRepository; +import reviewme.review.service.dto.response.list.ReceivedReviewsSummaryResponse; +import reviewme.review.service.exception.ReviewGroupNotFoundByReviewRequestCodeException; +import reviewme.reviewgroup.domain.ReviewGroup; +import reviewme.reviewgroup.repository.ReviewGroupRepository; +import reviewme.support.ServiceTest; +import reviewme.template.domain.Section; +import reviewme.template.domain.Template; +import reviewme.template.repository.SectionRepository; +import reviewme.template.repository.TemplateRepository; + +@ServiceTest +class ReviewSummaryServiceTest { + + @Autowired + private ReviewSummaryService reviewSummaryService; + + @Autowired + private ReviewGroupRepository reviewGroupRepository; + + @Autowired + private ReviewRepository reviewRepository; + + @Autowired + private TemplateRepository templateRepository; + + @Autowired + private SectionRepository sectionRepository; + + @Autowired + private QuestionRepository questionRepository; + + @Test + void 리뷰_그룹에_등록된_리뷰_요약_정보를_반환한다() { + // given + Question question = questionRepository.save(서술형_필수_질문()); + Section section = sectionRepository.save(항상_보이는_섹션(List.of(question.getId()))); + Template template = templateRepository.save(템플릿(List.of(section.getId()))); + + ReviewGroup reviewGroup1 = reviewGroupRepository.save(리뷰_그룹()); + ReviewGroup reviewGroup2 = reviewGroupRepository.save(리뷰_그룹("reReCo", "groupCo")); + + List reviews = List.of( + new Review(template.getId(), reviewGroup1.getId(), List.of()), + new Review(template.getId(), reviewGroup1.getId(), List.of()), + new Review(template.getId(), reviewGroup1.getId(), List.of()) + ); + reviewRepository.saveAll(reviews); + reviewRepository.save(new Review(template.getId(), reviewGroup2.getId(), List.of())); + + // when + ReceivedReviewsSummaryResponse actual = reviewSummaryService.getReviewSummary( + reviewGroup1.getReviewRequestCode()); + + // then + assertAll( + () -> assertThat(actual.projectName()).isEqualTo(reviewGroup1.getProjectName()), + () -> assertThat(actual.revieweeName()).isEqualTo(reviewGroup1.getReviewee()), + () -> assertThat(actual.totalReviewCount()).isEqualTo(reviews.size()) + ); + } + + @Test + void 리뷰_요약_정보_조회시_리뷰_요청_코드가_존재하지_않는_경우_예외가_발생한다() { + // given + ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹()); + + // when, then + assertThatThrownBy(() -> reviewSummaryService.getReviewSummary( + reviewGroup.getReviewRequestCode() + "wrong")) + .isInstanceOf(ReviewGroupNotFoundByReviewRequestCodeException.class); + } +} From ee2c95055f67728a5a9927390ba6b03b67bff914 Mon Sep 17 00:00:00 2001 From: Hyeonji <110809927+skylar1220@users.noreply.github.com> Date: Fri, 11 Oct 2024 16:32:54 +0900 Subject: [PATCH 13/49] =?UTF-8?q?[BE]=20feat:=20=ED=95=98=EC=9D=B4?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=ED=8A=B8=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20API=20=EA=B5=AC=ED=98=84=20(#813)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 하이라이트 기능을 위한 도메인, 테이블, 레포지토리 생성 * refactor: reviewRequestCode도 서비스에 함께 넘기도록 수정 * feat: 하이라이트 수정을 위한 삭제, 저장 로직 구현 * feat: 하이라이트 수정을 위한 검증 로직 구현 * db: 하이라이트 조회 성능과 동시성을 위한 복합 인덱스 추가 * fix: 리뷰 그룹으로 질문 검증하는 로직 수정 * refactor: 레포지토리에 기존에 있던 메서드 활용하도록 수정 * fix: 테이블명 오류 수정 * refactor: 초기화시 필드 순서 수정 * test: 하이라이트 레포지토리 테스트 추가 * test: 하이라이트 입력 검증 테스트 추가 * test: 하이라이트 수정 기능 서비스 테스트 추가 * refactor: 생성자 파라미터 및 필드 순서 변경 * fix: not null 설정 오타 수정 * refactor: 검증 순서 변경 * refactor: jpa 기본 메서드로 쿼리 여러개 나가는 부분을 @Query로 변경 * style: 개행 수정 * refactor: 에러 메세지 수정 * db: 하이라이트 인덱스 관련 데이터 타입 int로 수정 * refactor: 하이라이트 표시 위치 관련 속성을 객체로 분리 * refactor: JPQL로 변경 * refactor: 기존 레포지토리 메서드를 사용하도록 변경 * refactor: 메서드 네이밍 수정 * refactor: 하이라이트 위치 속성 객체에 추가 설정 * refactor: 하이라이트 엔티티에서 reviewGroupId, questionId 필드 제거 * refactor: 기존 하이라이트 제거를 answerId를 통해 하도록 변경 * test: 책임을 이동한 테스트 제거 및 테스트 오류 수정 * db: 테이블에 questionId, review_group_id 컬럼 제거 반영 * refactor: 기존 중복 요청 메서드 제거 * refactor: 중복 요청의 경우 하나만 저장하도록 EqualsAndHashCode 재정의 * refactor: 하이라이트 저장시 순서 보장 적용 * refactor: HighlightPosition 검증, 객체 생성 후 하이라이트 객체 생성하도록 변경 * refactor: 엔티티의 equals 조건 id로 복원 및 중복 제거 로직 삭제 * refactor: HighlightPosition 객체 생성 위치 생성자 내로 이동 * refactor: 예외명 수정 * refactor: 하이라이트 인덱스의 검증 추가 * refactor: 양수 검증 메서드명 수정 * test: 필드에 접근 제어자 추가 * fix: 하이라이트 줄 번호 계산 로직 및 변수명 수정 --- .../controller/HighlightController.java | 8 +- .../reviewme/highlight/domain/Highlight.java | 36 +++++ .../highlight/domain/HighlightPosition.java | 46 +++++++ ...ightStartIndexExceedEndIndexException.java | 13 ++ .../NegativeHighlightIndexException.java | 13 ++ .../repository/HighlightRepository.java | 7 + .../highlight/service/HighlightService.java | 57 +++++++- .../HighlightDuplicatedException.java | 14 ++ .../InvalidHighlightLineIndexException.java | 14 ++ ...werAndProvidedAnswerMismatchException.java | 16 +++ .../service/validator/HighlightValidator.java | 87 ++++++++++++ .../review/repository/AnswerRepository.java | 35 +++++ .../AnswerNotFoundByIdException.java | 13 ++ ...nAndProvidedQuestionMismatchException.java | 6 + .../migration/V3__create_highlight_table.sql | 14 ++ .../domain/HighlightPositionTest.java | 23 ++++ .../service/HighlightServiceTest.java | 126 +++++++++++++++++ .../validator/HighlightValidatorTest.java | 127 ++++++++++++++++++ .../repository/AnswerRepositoryTest.java | 69 ++++++++++ 19 files changed, 719 insertions(+), 5 deletions(-) create mode 100644 backend/src/main/java/reviewme/highlight/domain/Highlight.java create mode 100644 backend/src/main/java/reviewme/highlight/domain/HighlightPosition.java create mode 100644 backend/src/main/java/reviewme/highlight/domain/exception/HighlightStartIndexExceedEndIndexException.java create mode 100644 backend/src/main/java/reviewme/highlight/domain/exception/NegativeHighlightIndexException.java create mode 100644 backend/src/main/java/reviewme/highlight/repository/HighlightRepository.java create mode 100644 backend/src/main/java/reviewme/highlight/service/exception/HighlightDuplicatedException.java create mode 100644 backend/src/main/java/reviewme/highlight/service/exception/InvalidHighlightLineIndexException.java create mode 100644 backend/src/main/java/reviewme/highlight/service/exception/SubmittedAnswerAndProvidedAnswerMismatchException.java create mode 100644 backend/src/main/java/reviewme/highlight/service/validator/HighlightValidator.java create mode 100644 backend/src/main/java/reviewme/review/repository/AnswerRepository.java create mode 100644 backend/src/main/java/reviewme/review/service/exception/AnswerNotFoundByIdException.java create mode 100644 backend/src/main/resources/db/migration/V3__create_highlight_table.sql create mode 100644 backend/src/test/java/reviewme/highlight/domain/HighlightPositionTest.java create mode 100644 backend/src/test/java/reviewme/highlight/service/HighlightServiceTest.java create mode 100644 backend/src/test/java/reviewme/highlight/service/validator/HighlightValidatorTest.java create mode 100644 backend/src/test/java/reviewme/review/repository/AnswerRepositoryTest.java diff --git a/backend/src/main/java/reviewme/highlight/controller/HighlightController.java b/backend/src/main/java/reviewme/highlight/controller/HighlightController.java index 8a9a7f0b2..286d82a85 100644 --- a/backend/src/main/java/reviewme/highlight/controller/HighlightController.java +++ b/backend/src/main/java/reviewme/highlight/controller/HighlightController.java @@ -17,9 +17,11 @@ public class HighlightController { private final HighlightService highlightService; @PostMapping("/v2/highlight") - public ResponseEntity highlight(@Valid @RequestBody HighlightsRequest request, - @SessionAttribute("reviewRequestCode") String reviewRequestCode) { - highlightService.highlight(request); + public ResponseEntity highlight( + @Valid @RequestBody HighlightsRequest request, + @SessionAttribute("reviewRequestCode") String reviewRequestCode + ) { + highlightService.highlight(request, reviewRequestCode); return ResponseEntity.ok().build(); } } diff --git a/backend/src/main/java/reviewme/highlight/domain/Highlight.java b/backend/src/main/java/reviewme/highlight/domain/Highlight.java new file mode 100644 index 000000000..ba2a85ce5 --- /dev/null +++ b/backend/src/main/java/reviewme/highlight/domain/Highlight.java @@ -0,0 +1,36 @@ +package reviewme.highlight.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "highlight") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EqualsAndHashCode(of = "id") +@Getter +public class Highlight { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "answer_id", nullable = false) + private long answerId; + + @Embedded + private HighlightPosition highlightPosition; + + public Highlight(long answerId, long lineIndex, long startIndex, long endIndex) { + this.answerId = answerId; + this.highlightPosition = new HighlightPosition(lineIndex, startIndex, endIndex); + } +} diff --git a/backend/src/main/java/reviewme/highlight/domain/HighlightPosition.java b/backend/src/main/java/reviewme/highlight/domain/HighlightPosition.java new file mode 100644 index 000000000..cd03bb5b7 --- /dev/null +++ b/backend/src/main/java/reviewme/highlight/domain/HighlightPosition.java @@ -0,0 +1,46 @@ +package reviewme.highlight.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import reviewme.highlight.domain.exception.HighlightStartIndexExceedEndIndexException; +import reviewme.highlight.domain.exception.NegativeHighlightIndexException; + +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +@EqualsAndHashCode +public class HighlightPosition { + + @Column(name = "line_index", nullable = false) + private long lineIndex; + + @Column(name = "start_index", nullable = false) + private long startIndex; + + @Column(name = "end_index", nullable = false) + private long endIndex; + + public HighlightPosition(long lineIndex, long startIndex, long endIndex) { + validateNonNegativeIndexNumber(startIndex, endIndex); + validateEndIndexOverStartIndex(startIndex, endIndex); + this.lineIndex = lineIndex; + this.startIndex = startIndex; + this.endIndex = endIndex; + } + + private void validateNonNegativeIndexNumber(long startIndex, long endIndex) { + if (startIndex < 0 || endIndex < 0) { + throw new NegativeHighlightIndexException(startIndex, endIndex); + } + } + + private void validateEndIndexOverStartIndex(long startIndex, long endIndex) { + if (startIndex > endIndex) { + throw new HighlightStartIndexExceedEndIndexException(startIndex, endIndex); + } + } +} diff --git a/backend/src/main/java/reviewme/highlight/domain/exception/HighlightStartIndexExceedEndIndexException.java b/backend/src/main/java/reviewme/highlight/domain/exception/HighlightStartIndexExceedEndIndexException.java new file mode 100644 index 000000000..735ba06b6 --- /dev/null +++ b/backend/src/main/java/reviewme/highlight/domain/exception/HighlightStartIndexExceedEndIndexException.java @@ -0,0 +1,13 @@ +package reviewme.highlight.domain.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.BadRequestException; + +@Slf4j +public class HighlightStartIndexExceedEndIndexException extends BadRequestException { + + public HighlightStartIndexExceedEndIndexException(long startIndex, long endIndex) { + super("하이라이트 끝 위치는 시작 위치보다 같거나 커야 해요."); + log.info("Highlight start index exceed end index - startIndex: {}, endIndex: {}", startIndex, endIndex); + } +} diff --git a/backend/src/main/java/reviewme/highlight/domain/exception/NegativeHighlightIndexException.java b/backend/src/main/java/reviewme/highlight/domain/exception/NegativeHighlightIndexException.java new file mode 100644 index 000000000..9d2a32f00 --- /dev/null +++ b/backend/src/main/java/reviewme/highlight/domain/exception/NegativeHighlightIndexException.java @@ -0,0 +1,13 @@ +package reviewme.highlight.domain.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.BadRequestException; + +@Slf4j +public class NegativeHighlightIndexException extends BadRequestException { + + public NegativeHighlightIndexException(long startIndex, long endIndex) { + super("하이라이트 위치는 1 이상의 수이어야 해요."); + log.info("Highlight index is a negative number - startIndex: {}, endIndex: {}", startIndex, endIndex); + } +} diff --git a/backend/src/main/java/reviewme/highlight/repository/HighlightRepository.java b/backend/src/main/java/reviewme/highlight/repository/HighlightRepository.java new file mode 100644 index 000000000..c914d9750 --- /dev/null +++ b/backend/src/main/java/reviewme/highlight/repository/HighlightRepository.java @@ -0,0 +1,7 @@ +package reviewme.highlight.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import reviewme.highlight.domain.Highlight; + +public interface HighlightRepository extends JpaRepository { +} diff --git a/backend/src/main/java/reviewme/highlight/service/HighlightService.java b/backend/src/main/java/reviewme/highlight/service/HighlightService.java index e14ea984d..d123cff8a 100644 --- a/backend/src/main/java/reviewme/highlight/service/HighlightService.java +++ b/backend/src/main/java/reviewme/highlight/service/HighlightService.java @@ -1,12 +1,65 @@ package reviewme.highlight.service; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import reviewme.highlight.domain.Highlight; +import reviewme.highlight.repository.HighlightRepository; +import reviewme.highlight.service.dto.HighlightIndexRangeRequest; +import reviewme.highlight.service.dto.HighlightRequest; +import reviewme.highlight.service.dto.HighlightedLineRequest; import reviewme.highlight.service.dto.HighlightsRequest; +import reviewme.highlight.service.validator.HighlightValidator; +import reviewme.review.domain.Answer; +import reviewme.review.repository.AnswerRepository; +import reviewme.review.service.exception.ReviewGroupNotFoundByReviewRequestCodeException; +import reviewme.reviewgroup.repository.ReviewGroupRepository; @Service +@RequiredArgsConstructor public class HighlightService { - public void highlight(HighlightsRequest request) { - // TODO: implement method + private final HighlightRepository highlightRepository; + private final ReviewGroupRepository reviewGroupRepository; + private final AnswerRepository answerRepository; + + private final HighlightValidator highlightValidator; + + @Transactional + public void highlight(HighlightsRequest request, String reviewRequestCode) { + long reviewGroupId = reviewGroupRepository.findByReviewRequestCode(reviewRequestCode) + .orElseThrow(() -> new ReviewGroupNotFoundByReviewRequestCodeException(reviewRequestCode)) + .getId(); + + highlightValidator.validate(request, reviewGroupId); + deleteOldHighlight(request.questionId(), reviewGroupId); + saveNewHighlight(request); + } + + private void deleteOldHighlight(long questionId, long reviewGroupId) { + Set answersByReviewGroup = answerRepository.findAllByReviewGroupId(reviewGroupId); + List answersByReviewQuestion = answersByReviewGroup.stream() + .filter(answer -> answer.getQuestionId() == questionId) + .map(Answer::getId) + .toList(); + + highlightRepository.deleteAllById(answersByReviewQuestion); + } + + private void saveNewHighlight(HighlightsRequest highlightsRequest) { + List highlights = new ArrayList<>(); + for (HighlightRequest highlight : highlightsRequest.highlights()) { + for (HighlightedLineRequest line : highlight.lines()) { + for (HighlightIndexRangeRequest range : line.ranges()) { + Highlight highLight = new Highlight(highlight.answerId(), + line.index(), range.startIndex(), range.endIndex()); + highlights.add(highLight); + } + } + } + highlightRepository.saveAll(highlights); } } diff --git a/backend/src/main/java/reviewme/highlight/service/exception/HighlightDuplicatedException.java b/backend/src/main/java/reviewme/highlight/service/exception/HighlightDuplicatedException.java new file mode 100644 index 000000000..de999bf84 --- /dev/null +++ b/backend/src/main/java/reviewme/highlight/service/exception/HighlightDuplicatedException.java @@ -0,0 +1,14 @@ +package reviewme.highlight.service.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.BadRequestException; + +@Slf4j +public class HighlightDuplicatedException extends BadRequestException { + + public HighlightDuplicatedException(long answerId, long lineIndex, long startIndex, long endIndex) { + super("중복된 하이라이트는 생성할 수 없어요."); + log.info("Highlight is duplicated - answerId: {}, lineIndex: {}, startIndex: {}, endIndex: {}", + answerId, lineIndex, startIndex, endIndex); + } +} diff --git a/backend/src/main/java/reviewme/highlight/service/exception/InvalidHighlightLineIndexException.java b/backend/src/main/java/reviewme/highlight/service/exception/InvalidHighlightLineIndexException.java new file mode 100644 index 000000000..7271c08a3 --- /dev/null +++ b/backend/src/main/java/reviewme/highlight/service/exception/InvalidHighlightLineIndexException.java @@ -0,0 +1,14 @@ +package reviewme.highlight.service.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.BadRequestException; + +@Slf4j +public class InvalidHighlightLineIndexException extends BadRequestException { + + public InvalidHighlightLineIndexException(long submittedLineIndex, long providedMaxLineIndex) { + super("줄 번호는 %d 이하여야해요.".formatted(providedMaxLineIndex)); + log.info("Line index is out of bound - maxIndex: {}, submittedLineIndex: {}", providedMaxLineIndex, + submittedLineIndex); + } +} diff --git a/backend/src/main/java/reviewme/highlight/service/exception/SubmittedAnswerAndProvidedAnswerMismatchException.java b/backend/src/main/java/reviewme/highlight/service/exception/SubmittedAnswerAndProvidedAnswerMismatchException.java new file mode 100644 index 000000000..0282bd983 --- /dev/null +++ b/backend/src/main/java/reviewme/highlight/service/exception/SubmittedAnswerAndProvidedAnswerMismatchException.java @@ -0,0 +1,16 @@ +package reviewme.highlight.service.exception; + +import java.util.Collection; +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.BadRequestException; + +@Slf4j +public class SubmittedAnswerAndProvidedAnswerMismatchException extends BadRequestException { + + public SubmittedAnswerAndProvidedAnswerMismatchException(Collection providedAnswerIds, + Collection submittedAnswerIds) { + super("제출된 응답이 제공된 응답과 일치하지 않아요."); + log.info("SubmittedAnswer and providedAnswer mismatch - providedAnswerIds: {}, submittedAnswerIds: {}", + providedAnswerIds, submittedAnswerIds); + } +} diff --git a/backend/src/main/java/reviewme/highlight/service/validator/HighlightValidator.java b/backend/src/main/java/reviewme/highlight/service/validator/HighlightValidator.java new file mode 100644 index 000000000..ad2e323b3 --- /dev/null +++ b/backend/src/main/java/reviewme/highlight/service/validator/HighlightValidator.java @@ -0,0 +1,87 @@ +package reviewme.highlight.service.validator; + +import java.util.List; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import reviewme.highlight.service.dto.HighlightRequest; +import reviewme.highlight.service.dto.HighlightedLineRequest; +import reviewme.highlight.service.dto.HighlightsRequest; +import reviewme.highlight.service.exception.InvalidHighlightLineIndexException; +import reviewme.highlight.service.exception.SubmittedAnswerAndProvidedAnswerMismatchException; +import reviewme.question.repository.QuestionRepository; +import reviewme.review.domain.TextAnswer; +import reviewme.review.repository.AnswerRepository; +import reviewme.review.repository.TextAnswerRepository; +import reviewme.review.service.exception.AnswerNotFoundByIdException; +import reviewme.review.service.exception.SubmittedQuestionAndProvidedQuestionMismatchException; +import reviewme.reviewgroup.repository.ReviewGroupRepository; + +@Component +@RequiredArgsConstructor +public class HighlightValidator { + + private final AnswerRepository answerRepository; + private final TextAnswerRepository textAnswerRepository; + private final QuestionRepository questionRepository; + private final ReviewGroupRepository reviewGroupRepository; + + public void validate(HighlightsRequest request, long reviewGroupId) { + validateReviewGroupContainsQuestion(request, reviewGroupId); + validateReviewGroupContainsAnswer(request, reviewGroupId); + validateQuestionContainsAnswer(request); + validateLineIndex(request); + // TODO: 중복 요청 검증 추가 예정 + } + + private void validateReviewGroupContainsQuestion(HighlightsRequest request, long reviewGroupId) { + long templateId = reviewGroupRepository.findById(reviewGroupId) + .orElseThrow() + .getTemplateId(); + Set providedQuestionIds = questionRepository.findAllQuestionIdByTemplateId(templateId); + long submittedQuestionId = request.questionId(); + + if (!providedQuestionIds.contains(submittedQuestionId)) { + throw new SubmittedQuestionAndProvidedQuestionMismatchException(submittedQuestionId, providedQuestionIds); + } + } + + private void validateReviewGroupContainsAnswer(HighlightsRequest request, long reviewGroupId) { + Set providedAnswerIds = answerRepository.findIdsByReviewGroupId(reviewGroupId); + List submittedAnswerIds = request.highlights() + .stream() + .map(HighlightRequest::answerId) + .toList(); + + if (!providedAnswerIds.containsAll(submittedAnswerIds)) { + throw new SubmittedAnswerAndProvidedAnswerMismatchException(providedAnswerIds, submittedAnswerIds); + } + } + + private void validateQuestionContainsAnswer(HighlightsRequest request) { + Set providedAnswerIds = answerRepository.findIdsByQuestionId(request.questionId()); + List submittedAnswerIds = request.highlights() + .stream() + .map(HighlightRequest::answerId) + .toList(); + + if (!providedAnswerIds.containsAll(submittedAnswerIds)) { + throw new SubmittedAnswerAndProvidedAnswerMismatchException(providedAnswerIds, submittedAnswerIds); + } + } + + private void validateLineIndex(HighlightsRequest request) { + for (HighlightRequest highlight : request.highlights()) { + TextAnswer textAnswer = textAnswerRepository.findById(highlight.answerId()) + .orElseThrow(() -> new AnswerNotFoundByIdException(highlight.answerId())); + long providedMaxLineIndex = textAnswer.getContent().lines().count() - 1; + + for (HighlightedLineRequest line : highlight.lines()) { + long submittedLineIndex = line.index(); + if (providedMaxLineIndex < submittedLineIndex) { + throw new InvalidHighlightLineIndexException(submittedLineIndex, providedMaxLineIndex); + } + } + } + } +} diff --git a/backend/src/main/java/reviewme/review/repository/AnswerRepository.java b/backend/src/main/java/reviewme/review/repository/AnswerRepository.java new file mode 100644 index 000000000..aa4814978 --- /dev/null +++ b/backend/src/main/java/reviewme/review/repository/AnswerRepository.java @@ -0,0 +1,35 @@ +package reviewme.review.repository; + +import java.util.Set; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; +import reviewme.review.domain.Answer; + +@Repository +public interface AnswerRepository extends JpaRepository { + + @Query(value = """ + SELECT a.id FROM Answer a + JOIN Review r + ON a.reviewId = r.id + WHERE r.reviewGroupId = :reviewGroupId + """) + Set findIdsByReviewGroupId(long reviewGroupId); + + @Query(value = """ + SELECT a FROM Answer a + JOIN Review r + ON a.reviewId = r.id + WHERE r.reviewGroupId = :reviewGroupId + """) + Set findAllByReviewGroupId(long reviewGroupId); + + @Query(value = """ + SELECT a.id FROM Answer a + JOIN Question q + ON a.questionId = q.id + WHERE q.id = :questionId + """) + Set findIdsByQuestionId(long questionId); +} diff --git a/backend/src/main/java/reviewme/review/service/exception/AnswerNotFoundByIdException.java b/backend/src/main/java/reviewme/review/service/exception/AnswerNotFoundByIdException.java new file mode 100644 index 000000000..aef381ffc --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/exception/AnswerNotFoundByIdException.java @@ -0,0 +1,13 @@ +package reviewme.review.service.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.NotFoundException; + +@Slf4j +public class AnswerNotFoundByIdException extends NotFoundException { + + public AnswerNotFoundByIdException(long answerId) { + super("답변을 찾을 수 없어요."); + log.info("Answer not found by id - answerId: {}", answerId); + } +} diff --git a/backend/src/main/java/reviewme/review/service/exception/SubmittedQuestionAndProvidedQuestionMismatchException.java b/backend/src/main/java/reviewme/review/service/exception/SubmittedQuestionAndProvidedQuestionMismatchException.java index 97b0f77d9..1924b1cf5 100644 --- a/backend/src/main/java/reviewme/review/service/exception/SubmittedQuestionAndProvidedQuestionMismatchException.java +++ b/backend/src/main/java/reviewme/review/service/exception/SubmittedQuestionAndProvidedQuestionMismatchException.java @@ -1,6 +1,7 @@ package reviewme.review.service.exception; import java.util.Collection; +import java.util.List; import lombok.extern.slf4j.Slf4j; import reviewme.global.exception.BadRequestException; @@ -15,4 +16,9 @@ public SubmittedQuestionAndProvidedQuestionMismatchException(Collection su submittedQuestionIds, providedQuestionIds, this ); } + + public SubmittedQuestionAndProvidedQuestionMismatchException(long submittedQuestionId, + Collection providedQuestionIds) { + this(List.of(submittedQuestionId), providedQuestionIds); + } } diff --git a/backend/src/main/resources/db/migration/V3__create_highlight_table.sql b/backend/src/main/resources/db/migration/V3__create_highlight_table.sql new file mode 100644 index 000000000..ea0538792 --- /dev/null +++ b/backend/src/main/resources/db/migration/V3__create_highlight_table.sql @@ -0,0 +1,14 @@ +-- highlight 테이블을 생성합니다. +-- 조회의 성능을 높이고, 인덱스 단위의 잠금으로 여러 사용자가 동시에 테이블에 접근해 수정할 수 있게 answer_id 컬럼에 인덱스를 추가합니다. + +CREATE TABLE highlight +( + id BIGINT AUTO_INCREMENT, + answer_id BIGINT NOT NULL, + line_index INT NOT NULL, + start_index INT NOT NULL, + end_index INT NOT NULL, + PRIMARY KEY (id) +); + +CREATE INDEX highlight_idx_answer_id ON highlight (answer_id); diff --git a/backend/src/test/java/reviewme/highlight/domain/HighlightPositionTest.java b/backend/src/test/java/reviewme/highlight/domain/HighlightPositionTest.java new file mode 100644 index 000000000..a858f1d2a --- /dev/null +++ b/backend/src/test/java/reviewme/highlight/domain/HighlightPositionTest.java @@ -0,0 +1,23 @@ +package reviewme.highlight.domain; + +import static org.assertj.core.api.Assertions.assertThatCode; + +import org.junit.jupiter.api.Test; +import reviewme.highlight.domain.exception.HighlightStartIndexExceedEndIndexException; +import reviewme.highlight.domain.exception.NegativeHighlightIndexException; + +class HighlightPositionTest { + + @Test + void 하이라이트의_시작_인덱스가_종료_인덱스보다_큰_경우_예외를_발생한다() { + assertThatCode(() -> new HighlightPosition(1, 2, 1)) + .isInstanceOf(HighlightStartIndexExceedEndIndexException.class); + } + + @Test + void 하이라이트의_인덱스들이_0보다_작은_경우_예외를_발생한다() { + assertThatCode(() -> new HighlightPosition(1, -2, -1)) + .isInstanceOf(NegativeHighlightIndexException.class); + + } +} diff --git a/backend/src/test/java/reviewme/highlight/service/HighlightServiceTest.java b/backend/src/test/java/reviewme/highlight/service/HighlightServiceTest.java new file mode 100644 index 000000000..d02a9909f --- /dev/null +++ b/backend/src/test/java/reviewme/highlight/service/HighlightServiceTest.java @@ -0,0 +1,126 @@ +package reviewme.highlight.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static reviewme.fixture.SectionFixture.항상_보이는_섹션; +import static reviewme.fixture.TemplateFixture.템플릿; + +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import reviewme.fixture.QuestionFixture; +import reviewme.fixture.ReviewGroupFixture; +import reviewme.highlight.domain.Highlight; +import reviewme.highlight.domain.HighlightPosition; +import reviewme.highlight.repository.HighlightRepository; +import reviewme.highlight.service.dto.HighlightIndexRangeRequest; +import reviewme.highlight.service.dto.HighlightRequest; +import reviewme.highlight.service.dto.HighlightedLineRequest; +import reviewme.highlight.service.dto.HighlightsRequest; +import reviewme.question.repository.QuestionRepository; +import reviewme.review.domain.Review; +import reviewme.review.domain.TextAnswer; +import reviewme.review.repository.ReviewRepository; +import reviewme.reviewgroup.repository.ReviewGroupRepository; +import reviewme.support.ServiceTest; +import reviewme.template.repository.SectionRepository; +import reviewme.template.repository.TemplateRepository; + +@ServiceTest +class HighlightServiceTest { + + @Autowired + private HighlightService highlightService; + + @Autowired + private HighlightRepository highlightRepository; + + @Autowired + private ReviewGroupRepository reviewGroupRepository; + + @Autowired + private ReviewRepository reviewRepository; + + @Autowired + private QuestionRepository questionRepository; + + @Autowired + private SectionRepository sectionRepository; + + @Autowired + private TemplateRepository templateRepository; + + @Test + void 하이라이트_반영을_요청하면_리뷰_그룹과_질문에_해당하는_기존_하이라이트를_모두_삭제한다() { + // given + long questionId = questionRepository.save(QuestionFixture.서술형_필수_질문()).getId(); + long sectionId = sectionRepository.save(항상_보이는_섹션(List.of(questionId))).getId(); + long templateId = templateRepository.save(템플릿(List.of(sectionId))).getId(); + String reviewRequestCode = "reviewRequestCode"; + long reviewGroupId = reviewGroupRepository.save(ReviewGroupFixture.리뷰_그룹(reviewRequestCode, "groupAccessCode")) + .getId(); + Highlight highlight1 = highlightRepository.save(new Highlight(1, 1, 1, 1)); + Highlight highlight2 = highlightRepository.save(new Highlight(2, 1, 1, 1)); + + TextAnswer textAnswer1 = new TextAnswer(questionId, "text answer1"); + TextAnswer textAnswer2 = new TextAnswer(questionId, "text answer2"); + Review review = reviewRepository.save(new Review(templateId, reviewGroupId, List.of(textAnswer1, textAnswer2))); + + HighlightIndexRangeRequest indexRangeRequest = new HighlightIndexRangeRequest(1L, 1L); + HighlightedLineRequest lineRequest = new HighlightedLineRequest(0L, List.of(indexRangeRequest)); + HighlightRequest highlightRequest1 = new HighlightRequest(textAnswer1.getId(), List.of(lineRequest)); + HighlightRequest highlightRequest2 = new HighlightRequest(textAnswer2.getId(), List.of(lineRequest)); + HighlightsRequest highlightsRequest = new HighlightsRequest( + questionId, List.of(highlightRequest1, highlightRequest2) + ); + + // when + highlightService.highlight(highlightsRequest, reviewRequestCode); + + // then + assertAll( + () -> assertThat(highlightRepository.existsById(highlight1.getId())).isFalse(), + () -> assertThat(highlightRepository.existsById(highlight2.getId())).isFalse() + ); + } + + @Test + void 하이라이트_반영을_요청하면_새로운_하이라이트가_저장된다() { + // given + long questionId = questionRepository.save(QuestionFixture.서술형_필수_질문()).getId(); + long sectionId = sectionRepository.save(항상_보이는_섹션(List.of(questionId))).getId(); + long templateId = templateRepository.save(템플릿(List.of(sectionId))).getId(); + String reviewRequestCode = "reviewRequestCode"; + long reviewGroupId = reviewGroupRepository.save(ReviewGroupFixture.리뷰_그룹(reviewRequestCode, "groupAccessCode")) + .getId(); + highlightRepository.save(new Highlight(1, 1, 1, 1)); + + TextAnswer textAnswer1 = new TextAnswer(questionId, "text answer1"); + TextAnswer textAnswer2 = new TextAnswer(questionId, "text answer2"); + Review review = reviewRepository.save(new Review(templateId, reviewGroupId, List.of(textAnswer1, textAnswer2))); + + long startIndex = 2L; + long endIndex = 2L; + long lineIndex = 0; + HighlightIndexRangeRequest indexRangeRequest = new HighlightIndexRangeRequest(startIndex, endIndex); + HighlightedLineRequest lineRequest1 = new HighlightedLineRequest(lineIndex, List.of(indexRangeRequest)); + HighlightedLineRequest lineRequest2 = new HighlightedLineRequest(lineIndex, List.of(indexRangeRequest)); + HighlightRequest highlightRequest1 = new HighlightRequest(textAnswer1.getId(), List.of(lineRequest1)); + HighlightRequest highlightRequest2 = new HighlightRequest(textAnswer2.getId(), List.of(lineRequest2)); + HighlightsRequest highlightsRequest = new HighlightsRequest(questionId, + List.of(highlightRequest1, highlightRequest2)); + + // when + highlightService.highlight(highlightsRequest, reviewRequestCode); + + // then + List highlights = highlightRepository.findAll(); + HighlightPosition position = new HighlightPosition(lineIndex, startIndex, endIndex); + assertAll( + () -> assertThat(highlights.get(0).getAnswerId()).isEqualTo(textAnswer1.getId()), + () -> assertThat(highlights.get(1).getAnswerId()).isEqualTo(textAnswer2.getId()), + () -> assertThat(highlights.get(0).getHighlightPosition()).isEqualTo(position), + () -> assertThat(highlights.get(0).getHighlightPosition()).isEqualTo(position) + ); + } +} diff --git a/backend/src/test/java/reviewme/highlight/service/validator/HighlightValidatorTest.java b/backend/src/test/java/reviewme/highlight/service/validator/HighlightValidatorTest.java new file mode 100644 index 000000000..10fddba5f --- /dev/null +++ b/backend/src/test/java/reviewme/highlight/service/validator/HighlightValidatorTest.java @@ -0,0 +1,127 @@ +package reviewme.highlight.service.validator; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static reviewme.fixture.SectionFixture.항상_보이는_섹션; +import static reviewme.fixture.TemplateFixture.템플릿; + +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import reviewme.fixture.QuestionFixture; +import reviewme.fixture.ReviewGroupFixture; +import reviewme.highlight.service.dto.HighlightRequest; +import reviewme.highlight.service.dto.HighlightedLineRequest; +import reviewme.highlight.service.dto.HighlightsRequest; +import reviewme.highlight.service.exception.InvalidHighlightLineIndexException; +import reviewme.highlight.service.exception.SubmittedAnswerAndProvidedAnswerMismatchException; +import reviewme.question.domain.Question; +import reviewme.question.repository.QuestionRepository; +import reviewme.review.domain.Review; +import reviewme.review.domain.TextAnswer; +import reviewme.review.repository.ReviewRepository; +import reviewme.review.service.exception.SubmittedQuestionAndProvidedQuestionMismatchException; +import reviewme.reviewgroup.domain.ReviewGroup; +import reviewme.reviewgroup.repository.ReviewGroupRepository; +import reviewme.support.ServiceTest; +import reviewme.template.domain.Section; +import reviewme.template.domain.Template; +import reviewme.template.repository.SectionRepository; +import reviewme.template.repository.TemplateRepository; + +@ServiceTest +class HighlightValidatorTest { + + @Autowired + private HighlightValidator highlightValidator; + + @Autowired + private ReviewGroupRepository reviewGroupRepository; + + @Autowired + private ReviewRepository reviewRepository; + + @Autowired + private QuestionRepository questionRepository; + + @Autowired + private SectionRepository sectionRepository; + + @Autowired + private TemplateRepository templateRepository; + + @Test + void 하이라이트의_질문_id가_리뷰_그룹의_템플릿에_속한_질문이_아니면_예외를_발생한다() { + // given + ReviewGroup reviewGroup = reviewGroupRepository.save(ReviewGroupFixture.리뷰_그룹()); + Question question = questionRepository.save(QuestionFixture.서술형_필수_질문()); + HighlightsRequest highlightsRequest = new HighlightsRequest(question.getId(), List.of()); + + // when && then + assertThatCode(() -> highlightValidator.validate(highlightsRequest, reviewGroup.getId())) + .isInstanceOf(SubmittedQuestionAndProvidedQuestionMismatchException.class); + } + + @Test + void 하이라이트의_답변_id가_리뷰_그룹에_달린_답변이_아니면_예외를_발생한다() { + // given + long questionId = questionRepository.save(QuestionFixture.서술형_필수_질문()).getId(); + Section section = sectionRepository.save(항상_보이는_섹션(List.of(questionId))); + Template template = templateRepository.save(템플릿(List.of(section.getId()))); + + ReviewGroup reviewGroup1 = reviewGroupRepository.save(ReviewGroupFixture.리뷰_그룹()); + ReviewGroup reviewGroup2 = reviewGroupRepository.save(ReviewGroupFixture.리뷰_그룹()); + TextAnswer textAnswer1 = new TextAnswer(questionId, "text answer1"); + TextAnswer textAnswer2 = new TextAnswer(questionId, "text answer2"); + reviewRepository.saveAll(List.of( + new Review(template.getId(), reviewGroup1.getId(), List.of(textAnswer1)), + new Review(template.getId(), reviewGroup2.getId(), List.of(textAnswer2)) + )); + + HighlightRequest highlightRequest = new HighlightRequest(textAnswer2.getId(), List.of()); + HighlightsRequest highlightsRequest = new HighlightsRequest(1L, List.of(highlightRequest)); + + // when && then + assertThatCode(() -> highlightValidator.validate(highlightsRequest, reviewGroup1.getId())) + .isInstanceOf(SubmittedAnswerAndProvidedAnswerMismatchException.class); + } + + @Test + void 하이라이트의_답변_id가_하이라이트의_질문_id에_해당하는_답변이_아니면_예외를_발생한다() { + // given + long questionId1 = questionRepository.save(QuestionFixture.서술형_필수_질문()).getId(); + long questionId2 = questionRepository.save(QuestionFixture.서술형_필수_질문()).getId(); + Section section = sectionRepository.save(항상_보이는_섹션(List.of(questionId1, questionId2))); + Template template = templateRepository.save(템플릿(List.of(section.getId()))); + + long reviewGroupId = reviewGroupRepository.save(ReviewGroupFixture.리뷰_그룹()).getId(); + TextAnswer textAnswer_Q1 = new TextAnswer(questionId1, "text answer 1"); + + HighlightRequest highlightRequest = new HighlightRequest(textAnswer_Q1.getId(), List.of()); + HighlightsRequest highlightsRequest = new HighlightsRequest(questionId2, List.of(highlightRequest)); + + // when && then + assertThatCode(() -> highlightValidator.validate(highlightsRequest, reviewGroupId)) + .isInstanceOf(SubmittedAnswerAndProvidedAnswerMismatchException.class); + } + + @Test + void 답변의_줄_수보다_하이라이트의_줄_번호가_더_크면_예외를_발생한다() { + // given + long questionId = questionRepository.save(QuestionFixture.서술형_필수_질문()).getId(); + long sectionId = sectionRepository.save(항상_보이는_섹션(List.of(questionId))).getId(); + long templateId = templateRepository.save(템플릿(List.of(sectionId))).getId(); + + TextAnswer textAnswer = new TextAnswer(questionId, "line 1\n line 2"); + long reviewGroupId = reviewGroupRepository.save(ReviewGroupFixture.리뷰_그룹()).getId(); + Review review = reviewRepository.save(new Review(templateId, reviewGroupId, List.of(textAnswer))); + + long answerLineCount = textAnswer.getContent().lines().count(); + HighlightedLineRequest highlightedLineRequest = new HighlightedLineRequest(answerLineCount, List.of()); + HighlightRequest highlightRequest = new HighlightRequest(textAnswer.getId(), List.of(highlightedLineRequest)); + HighlightsRequest highlightsRequest = new HighlightsRequest(questionId, List.of(highlightRequest)); + + // when & then + assertThatCode(() -> highlightValidator.validate(highlightsRequest, reviewGroupId)) + .isInstanceOf(InvalidHighlightLineIndexException.class); + } +} diff --git a/backend/src/test/java/reviewme/review/repository/AnswerRepositoryTest.java b/backend/src/test/java/reviewme/review/repository/AnswerRepositoryTest.java new file mode 100644 index 000000000..bd76f054d --- /dev/null +++ b/backend/src/test/java/reviewme/review/repository/AnswerRepositoryTest.java @@ -0,0 +1,69 @@ +package reviewme.review.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import reviewme.fixture.QuestionFixture; +import reviewme.fixture.ReviewGroupFixture; +import reviewme.question.repository.QuestionRepository; +import reviewme.review.domain.Review; +import reviewme.review.domain.TextAnswer; +import reviewme.reviewgroup.domain.ReviewGroup; +import reviewme.reviewgroup.repository.ReviewGroupRepository; + +@DataJpaTest +class AnswerRepositoryTest { + + @Autowired + AnswerRepository answerRepository; + + @Autowired + ReviewGroupRepository reviewGroupRepository; + + @Autowired + ReviewRepository reviewRepository; + + @Autowired + QuestionRepository questionRepository; + + @Test + void 리뷰_그룹_id로_리뷰들을_찾아_id를_반환한다() { + // given + ReviewGroup reviewGroup = reviewGroupRepository.save(ReviewGroupFixture.리뷰_그룹()); + TextAnswer answer1 = new TextAnswer(1L, "text answer1"); + TextAnswer answer2 = new TextAnswer(1L, "text answer2"); + Review review = reviewRepository.save(new Review(1L, reviewGroup.getId(), List.of(answer1, answer2))); + + // when + Set actual = answerRepository.findIdsByReviewGroupId(reviewGroup.getId()); + + // then + assertThat(actual).containsExactly(answer1.getId(), answer2.getId()); + } + + @Test + void 질문_id로_리뷰들을_찾아_id를_반환한다() { + // given + ReviewGroup reviewGroup = reviewGroupRepository.save(ReviewGroupFixture.리뷰_그룹()); + long questionId1 = questionRepository.save(QuestionFixture.서술형_필수_질문()).getId(); + long questionId2 = questionRepository.save(QuestionFixture.서술형_필수_질문()).getId(); + TextAnswer textAnswer1_Q1 = new TextAnswer(questionId1, "text answer1 by Q1"); + TextAnswer textAnswer2_Q1 = new TextAnswer(questionId1, "text answer2 by Q1"); + TextAnswer textAnswer1_Q2 = new TextAnswer(questionId2, "text answer1 by Q2"); + + reviewRepository.saveAll(List.of( + new Review(1L, reviewGroup.getId(), List.of(textAnswer1_Q1, textAnswer2_Q1)), + new Review(1L, reviewGroup.getId(), List.of(textAnswer1_Q2) + ))); + + // when + Set actual = answerRepository.findIdsByQuestionId(questionId1); + + // then + assertThat(actual).containsExactly(textAnswer1_Q1.getId(), textAnswer2_Q1.getId()); + } +} From 7e5564b71819e91f6cae330789ddad3c05e3e730 Mon Sep 17 00:00:00 2001 From: Yeongseo Na Date: Fri, 11 Oct 2024 17:01:58 +0900 Subject: [PATCH 14/49] =?UTF-8?q?[BE]=20feat:=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EB=AA=A8=EC=95=84=EB=B3=B4=EA=B8=B0=20API=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=20(#806)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: dto 이름 변경 AnswerContentResponse -> TextResponse * feat: 질문ID에 해당하는 Answer 반환 함수 추가 * feat: 질문ID에 해당하는 OptionItem들 반환 함수 추가 * feat: 리뷰 요청 코드와 섹션ID에 해당하는 질문 반환 함수 추가 * feat: 리뷰 모이보기 API 구현 * refactor: 테스트에서 검증하고자 하는 것을 분명히 * refactor: 패키지 의존 방향 통일을 위한 레포지토리 함수 이동 * refactor: 테스트 목적에 해당하는 것만 남기기 * refactor: 함수 이름 변경 - 인자가 List 이므로, ids 로 끝나게 변경함 * refactor: 가동성을 위한 함수 분리 * refactor: 다른 서비스 클래스들과 네이밍 통일 * feat: 리뷰 요청 코드 검증, 섹션 아이디 검증 추가 * refactor: 섹션에 해당하는 질문 가져오는 함수 수정 AS-IS: 내 리뷰그룹 중 특정 세션에 대한 질문 가져오기 TO-BE: 리뷰그룹 검증 완료되었으므로 특정 세션에 대해서만 가져오도록 수정 * refactor: 답변 가져오는 함수 수정 AS-IS: 특정 질문에 대해서 답변된 것 다 받아오기 TO-BE: 내 리뷰 그룹에 해당한다는 조건 추가 * feat: 답변하지 않은 내용은 빈 배열로 받아오도록 하는 기능 추가 * fix: 깨지는 테스트 코드 봉합 * refactor: Mapper 분리 * refactor: 섹션 검증 방법 변경 AS-IS: boolean 으로 검증, sectionId 인자 그대로 사용 TO-BE: Optional
으로 검증, section.getId() 사용 * refactor: 필요없는 JOIN 제거 * test: 테스트 코드 목적별로 분리 * feat: 질문 목록을 position 순서대로 정렬 * refactor: 테스트 코드 extracting 사용 변경 AS-IS: attribute 값을 문자열로 하드코딩 TO-BE: dtp 의 필드명으로 가져오기 e.g. VoteResponse::content * chore: 오타 수정 * test: 다른 리뷰 그룹이 있는 상황에 대한 테스트 * refactor: 접근제어자 수정 * feat: 하이라이트 응답 추가 * test: 추가된 속성 반환 테스트 * refactor: 유연한 레포지토리 함수로 변경 * refactor: 변수명 변경 * refactor: 가독성 개선 함수 분리, 변수명 변경, 함수 인자 변경 * refactor: Answer -> 구체Answer 캐스팅 예외 추가 * refactor: 서술형 답변 가져오는 함수 수정 * refactor: 선택지 목록 가져오는 함수 수정 * test: reviewGatherMapper에 대한 테스트 작성 * style: 개행 - 가독성 개선와 컨벤션 유지를 위함 * chore: 로그 메세지 수정 - 컨벤션 유지를 위함 * refactor: 컨트롤러 인자 타입 변경 - Long -> long * feat: 최대로 내려주는 응답 수 제한 기능 구현 * refactor: 테스트 코드 필드 접근제한자 추가 * refactor: 변수명 변경 * chore: 사용하지 않는 메서드 제거 --- .../repository/QuestionRepository.java | 19 + .../review/controller/ReviewController.java | 8 +- .../review/repository/AnswerRepository.java | 21 +- .../service/GatheredReviewLookupService.java | 14 - .../service/ReviewGatheredLookupService.java | 73 ++++ .../gathered/AnswerContentResponse.java | 6 - .../response/gathered/HighlightResponse.java | 9 + .../dto/response/gathered/RangeResponse.java | 7 + .../ReviewsGatheredByQuestionResponse.java | 2 +- .../gathered/SimpleQuestionResponse.java | 1 + .../dto/response/gathered/TextResponse.java | 10 + .../dto/response/gathered/VoteResponse.java | 2 +- ...atheredAnswersTypeNonUniformException.java | 13 + .../SectionNotFoundInTemplateException.java | 13 + .../service/mapper/ReviewGatherMapper.java | 85 ++++ .../reviewgroup/domain/ReviewGroup.java | 5 + .../repository/SectionRepository.java | 9 + .../src/test/java/reviewme/api/ApiTest.java | 4 +- .../test/java/reviewme/api/ReviewApiTest.java | 30 +- .../repository/QuestionRepositoryTest.java | 61 +++ .../repository/AnswerRepositoryTest.java | 61 ++- .../ReviewGatheredLookupServiceTest.java | 395 ++++++++++++++++++ .../mapper/ReviewGatherMapperTest.java | 137 ++++++ .../repository/SectionRepositoryTest.java | 21 + 24 files changed, 954 insertions(+), 52 deletions(-) delete mode 100644 backend/src/main/java/reviewme/review/service/GatheredReviewLookupService.java create mode 100644 backend/src/main/java/reviewme/review/service/ReviewGatheredLookupService.java delete mode 100644 backend/src/main/java/reviewme/review/service/dto/response/gathered/AnswerContentResponse.java create mode 100644 backend/src/main/java/reviewme/review/service/dto/response/gathered/HighlightResponse.java create mode 100644 backend/src/main/java/reviewme/review/service/dto/response/gathered/RangeResponse.java create mode 100644 backend/src/main/java/reviewme/review/service/dto/response/gathered/TextResponse.java create mode 100644 backend/src/main/java/reviewme/review/service/exception/GatheredAnswersTypeNonUniformException.java create mode 100644 backend/src/main/java/reviewme/review/service/exception/SectionNotFoundInTemplateException.java create mode 100644 backend/src/main/java/reviewme/review/service/mapper/ReviewGatherMapper.java create mode 100644 backend/src/test/java/reviewme/review/service/ReviewGatheredLookupServiceTest.java create mode 100644 backend/src/test/java/reviewme/review/service/mapper/ReviewGatherMapperTest.java diff --git a/backend/src/main/java/reviewme/question/repository/QuestionRepository.java b/backend/src/main/java/reviewme/question/repository/QuestionRepository.java index fdeaea795..889202bf3 100644 --- a/backend/src/main/java/reviewme/question/repository/QuestionRepository.java +++ b/backend/src/main/java/reviewme/question/repository/QuestionRepository.java @@ -4,8 +4,11 @@ import java.util.Set; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; +import reviewme.question.domain.OptionItem; import reviewme.question.domain.Question; +@Repository public interface QuestionRepository extends JpaRepository { @Query(value = """ @@ -27,4 +30,20 @@ public interface QuestionRepository extends JpaRepository { WHERE ts.template_id = :templateId """, nativeQuery = true) List findAllByTemplatedId(long templateId); + + @Query(value = """ + SELECT q FROM Question q + JOIN SectionQuestion sq ON q.id = sq.questionId + WHERE sq.sectionId = :sectionId + ORDER BY q.position + """) + List findAllBySectionIdOrderByPosition(long sectionId); + + @Query(""" + SELECT o FROM OptionItem o + JOIN OptionGroup og ON o.optionGroupId = og.id + WHERE og.questionId = :questionId + ORDER BY o.position + """) + List findAllOptionItemsByIdOrderByPosition(long questionId); } diff --git a/backend/src/main/java/reviewme/review/controller/ReviewController.java b/backend/src/main/java/reviewme/review/controller/ReviewController.java index 7bca618ce..01e091756 100644 --- a/backend/src/main/java/reviewme/review/controller/ReviewController.java +++ b/backend/src/main/java/reviewme/review/controller/ReviewController.java @@ -11,7 +11,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.SessionAttribute; -import reviewme.review.service.GatheredReviewLookupService; +import reviewme.review.service.ReviewGatheredLookupService; import reviewme.review.service.ReviewDetailLookupService; import reviewme.review.service.ReviewListLookupService; import reviewme.review.service.ReviewRegisterService; @@ -30,7 +30,7 @@ public class ReviewController { private final ReviewListLookupService reviewListLookupService; private final ReviewDetailLookupService reviewDetailLookupService; private final ReviewSummaryService reviewSummaryService; - private final GatheredReviewLookupService gatheredReviewLookupService; + private final ReviewGatheredLookupService reviewGatheredLookupService; @PostMapping("/v2/reviews") public ResponseEntity createReview(@Valid @RequestBody ReviewRegisterRequest request) { @@ -68,10 +68,10 @@ public ResponseEntity findReceivedReviewOverview @GetMapping("/v2/reviews/gather") public ResponseEntity getReceivedReviewsBySectionId( - @RequestParam("sectionId") Long sectionId, + @RequestParam("sectionId") long sectionId, @SessionAttribute("reviewRequestCode") String reviewRequestCode ) { - ReviewsGatheredBySectionResponse response = gatheredReviewLookupService.getReceivedReviewsBySectionId( + ReviewsGatheredBySectionResponse response = reviewGatheredLookupService.getReceivedReviewsBySectionId( reviewRequestCode, sectionId); return ResponseEntity.ok(response); } diff --git a/backend/src/main/java/reviewme/review/repository/AnswerRepository.java b/backend/src/main/java/reviewme/review/repository/AnswerRepository.java index aa4814978..821fbb7cc 100644 --- a/backend/src/main/java/reviewme/review/repository/AnswerRepository.java +++ b/backend/src/main/java/reviewme/review/repository/AnswerRepository.java @@ -1,5 +1,7 @@ package reviewme.review.repository; +import java.util.Collection; +import java.util.List; import java.util.Set; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -9,6 +11,15 @@ @Repository public interface AnswerRepository extends JpaRepository { + @Query(value = """ + SELECT a FROM Answer a + JOIN Review r ON a.reviewId = r.id + WHERE r.reviewGroupId = :reviewGroupId AND a.questionId IN :questionIds + ORDER BY r.createdAt DESC + LIMIT :limit + """) + List findReceivedAnswersByQuestionIds(long reviewGroupId, Collection questionIds, int limit); + @Query(value = """ SELECT a.id FROM Answer a JOIN Review r @@ -19,17 +30,15 @@ public interface AnswerRepository extends JpaRepository { @Query(value = """ SELECT a FROM Answer a - JOIN Review r - ON a.reviewId = r.id - WHERE r.reviewGroupId = :reviewGroupId + JOIN Review r + ON a.reviewId = r.id + WHERE r.reviewGroupId = :reviewGroupId """) Set findAllByReviewGroupId(long reviewGroupId); @Query(value = """ SELECT a.id FROM Answer a - JOIN Question q - ON a.questionId = q.id - WHERE q.id = :questionId + WHERE a.questionId = :questionId """) Set findIdsByQuestionId(long questionId); } diff --git a/backend/src/main/java/reviewme/review/service/GatheredReviewLookupService.java b/backend/src/main/java/reviewme/review/service/GatheredReviewLookupService.java deleted file mode 100644 index a48e59525..000000000 --- a/backend/src/main/java/reviewme/review/service/GatheredReviewLookupService.java +++ /dev/null @@ -1,14 +0,0 @@ -package reviewme.review.service; - -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import reviewme.review.service.dto.response.gathered.ReviewsGatheredBySectionResponse; - -@Service -@RequiredArgsConstructor -public class GatheredReviewLookupService { - - public ReviewsGatheredBySectionResponse getReceivedReviewsBySectionId(String reviewRequestCode, long sectionId) { - return null; - } -} diff --git a/backend/src/main/java/reviewme/review/service/ReviewGatheredLookupService.java b/backend/src/main/java/reviewme/review/service/ReviewGatheredLookupService.java new file mode 100644 index 000000000..86bb7d728 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/ReviewGatheredLookupService.java @@ -0,0 +1,73 @@ +package reviewme.review.service; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import reviewme.question.domain.Question; +import reviewme.question.repository.QuestionRepository; +import reviewme.review.domain.Answer; +import reviewme.review.repository.AnswerRepository; +import reviewme.review.service.dto.response.gathered.ReviewsGatheredBySectionResponse; +import reviewme.review.service.exception.ReviewGroupNotFoundByReviewRequestCodeException; +import reviewme.review.service.exception.SectionNotFoundInTemplateException; +import reviewme.review.service.mapper.ReviewGatherMapper; +import reviewme.reviewgroup.domain.ReviewGroup; +import reviewme.reviewgroup.repository.ReviewGroupRepository; +import reviewme.template.domain.Section; +import reviewme.template.repository.SectionRepository; + +@Service +@RequiredArgsConstructor +public class ReviewGatheredLookupService { + + private static final int ANSWER_RESPONSE_LIMIT = 100; + + private final QuestionRepository questionRepository; + private final AnswerRepository answerRepository; + private final ReviewGroupRepository reviewGroupRepository; + private final SectionRepository sectionRepository; + + private final ReviewGatherMapper reviewGatherMapper; + + @Transactional(readOnly = true) + public ReviewsGatheredBySectionResponse getReceivedReviewsBySectionId(String reviewRequestCode, long sectionId) { + ReviewGroup reviewGroup = getReviewGroupOrThrow(reviewRequestCode); + Section section = getSectionOrThrow(sectionId, reviewGroup); + Map> questionAnswers = getQuestionAnswers(section, reviewGroup); + + return reviewGatherMapper.mapToReviewsGatheredBySection(questionAnswers); + } + + private ReviewGroup getReviewGroupOrThrow(String reviewRequestCode) { + return reviewGroupRepository.findByReviewRequestCode(reviewRequestCode) + .orElseThrow(() -> new ReviewGroupNotFoundByReviewRequestCodeException(reviewRequestCode)); + } + + private Section getSectionOrThrow(long sectionId, ReviewGroup reviewGroup) { + return sectionRepository.findByIdAndTemplateId(sectionId, reviewGroup.getTemplateId()) + .orElseThrow(() -> new SectionNotFoundInTemplateException(sectionId, reviewGroup.getTemplateId())); + } + + private Map> getQuestionAnswers(Section section, ReviewGroup reviewGroup) { + Map questionIdQuestion = questionRepository + .findAllBySectionIdOrderByPosition(section.getId()) + .stream() + .collect(Collectors.toMap(Question::getId, Function.identity())); + + Map> questionIdAnswers = answerRepository + .findReceivedAnswersByQuestionIds(reviewGroup.getId(), questionIdQuestion.keySet(), + ANSWER_RESPONSE_LIMIT) + .stream() + .collect(Collectors.groupingBy(Answer::getQuestionId)); + + return questionIdQuestion.values().stream() + .collect(Collectors.toMap( + Function.identity(), + question -> questionIdAnswers.getOrDefault(question.getId(), List.of()) + )); + } +} diff --git a/backend/src/main/java/reviewme/review/service/dto/response/gathered/AnswerContentResponse.java b/backend/src/main/java/reviewme/review/service/dto/response/gathered/AnswerContentResponse.java deleted file mode 100644 index df73eb56c..000000000 --- a/backend/src/main/java/reviewme/review/service/dto/response/gathered/AnswerContentResponse.java +++ /dev/null @@ -1,6 +0,0 @@ -package reviewme.review.service.dto.response.gathered; - -public record AnswerContentResponse( - String content -) { -} diff --git a/backend/src/main/java/reviewme/review/service/dto/response/gathered/HighlightResponse.java b/backend/src/main/java/reviewme/review/service/dto/response/gathered/HighlightResponse.java new file mode 100644 index 000000000..402cc9b09 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/dto/response/gathered/HighlightResponse.java @@ -0,0 +1,9 @@ +package reviewme.review.service.dto.response.gathered; + +import java.util.List; + +public record HighlightResponse( + long lineIndex, + List ranges +) { +} diff --git a/backend/src/main/java/reviewme/review/service/dto/response/gathered/RangeResponse.java b/backend/src/main/java/reviewme/review/service/dto/response/gathered/RangeResponse.java new file mode 100644 index 000000000..046b02f73 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/dto/response/gathered/RangeResponse.java @@ -0,0 +1,7 @@ +package reviewme.review.service.dto.response.gathered; + +public record RangeResponse( + long startIndex, + long endIndex +) { +} diff --git a/backend/src/main/java/reviewme/review/service/dto/response/gathered/ReviewsGatheredByQuestionResponse.java b/backend/src/main/java/reviewme/review/service/dto/response/gathered/ReviewsGatheredByQuestionResponse.java index 9658e80d9..426049908 100644 --- a/backend/src/main/java/reviewme/review/service/dto/response/gathered/ReviewsGatheredByQuestionResponse.java +++ b/backend/src/main/java/reviewme/review/service/dto/response/gathered/ReviewsGatheredByQuestionResponse.java @@ -7,7 +7,7 @@ public record ReviewsGatheredByQuestionResponse( SimpleQuestionResponse question, @Nullable - List answers, + List answers, @Nullable List votes diff --git a/backend/src/main/java/reviewme/review/service/dto/response/gathered/SimpleQuestionResponse.java b/backend/src/main/java/reviewme/review/service/dto/response/gathered/SimpleQuestionResponse.java index 1c9618a72..e16df25e6 100644 --- a/backend/src/main/java/reviewme/review/service/dto/response/gathered/SimpleQuestionResponse.java +++ b/backend/src/main/java/reviewme/review/service/dto/response/gathered/SimpleQuestionResponse.java @@ -3,6 +3,7 @@ import reviewme.question.domain.QuestionType; public record SimpleQuestionResponse( + long id, String name, QuestionType type ) { diff --git a/backend/src/main/java/reviewme/review/service/dto/response/gathered/TextResponse.java b/backend/src/main/java/reviewme/review/service/dto/response/gathered/TextResponse.java new file mode 100644 index 000000000..3684f09e1 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/dto/response/gathered/TextResponse.java @@ -0,0 +1,10 @@ +package reviewme.review.service.dto.response.gathered; + +import java.util.List; + +public record TextResponse( + long id, + String content, + List highlights +) { +} diff --git a/backend/src/main/java/reviewme/review/service/dto/response/gathered/VoteResponse.java b/backend/src/main/java/reviewme/review/service/dto/response/gathered/VoteResponse.java index 57ba21e0d..a2dc887ca 100644 --- a/backend/src/main/java/reviewme/review/service/dto/response/gathered/VoteResponse.java +++ b/backend/src/main/java/reviewme/review/service/dto/response/gathered/VoteResponse.java @@ -2,6 +2,6 @@ public record VoteResponse( String content, - int count + long count ) { } diff --git a/backend/src/main/java/reviewme/review/service/exception/GatheredAnswersTypeNonUniformException.java b/backend/src/main/java/reviewme/review/service/exception/GatheredAnswersTypeNonUniformException.java new file mode 100644 index 000000000..3d13fd987 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/exception/GatheredAnswersTypeNonUniformException.java @@ -0,0 +1,13 @@ +package reviewme.review.service.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.DataInconsistencyException; + +@Slf4j +public class GatheredAnswersTypeNonUniformException extends DataInconsistencyException { + + public GatheredAnswersTypeNonUniformException(Throwable cause) { + super("서버 내부 오류가 발생했습니다."); + log.error("The types of answers to questions are not uniform.", cause); + } +} diff --git a/backend/src/main/java/reviewme/review/service/exception/SectionNotFoundInTemplateException.java b/backend/src/main/java/reviewme/review/service/exception/SectionNotFoundInTemplateException.java new file mode 100644 index 000000000..9941c8c8a --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/exception/SectionNotFoundInTemplateException.java @@ -0,0 +1,13 @@ +package reviewme.review.service.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.NotFoundException; + +@Slf4j +public class SectionNotFoundInTemplateException extends NotFoundException { + + public SectionNotFoundInTemplateException(long sectionId, long templateId) { + super("섹션 정보를 찾을 수 없습니다."); + log.info("Section not found in template - sectionId: {}, templateId: {}", sectionId, templateId, this); + } +} diff --git a/backend/src/main/java/reviewme/review/service/mapper/ReviewGatherMapper.java b/backend/src/main/java/reviewme/review/service/mapper/ReviewGatherMapper.java new file mode 100644 index 000000000..a21a6da6f --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/mapper/ReviewGatherMapper.java @@ -0,0 +1,85 @@ +package reviewme.review.service.mapper; + +import jakarta.annotation.Nullable; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import reviewme.question.domain.OptionItem; +import reviewme.question.domain.Question; +import reviewme.question.repository.QuestionRepository; +import reviewme.review.domain.Answer; +import reviewme.review.domain.CheckboxAnswer; +import reviewme.review.domain.CheckboxAnswerSelectedOption; +import reviewme.review.domain.TextAnswer; +import reviewme.review.service.dto.response.gathered.ReviewsGatheredByQuestionResponse; +import reviewme.review.service.dto.response.gathered.ReviewsGatheredBySectionResponse; +import reviewme.review.service.dto.response.gathered.SimpleQuestionResponse; +import reviewme.review.service.dto.response.gathered.TextResponse; +import reviewme.review.service.dto.response.gathered.VoteResponse; +import reviewme.review.service.exception.GatheredAnswersTypeNonUniformException; + +@Component +@RequiredArgsConstructor +public class ReviewGatherMapper { + + private final QuestionRepository questionRepository; + + public ReviewsGatheredBySectionResponse mapToReviewsGatheredBySection(Map> questionAnswers) { + List reviews = questionAnswers.entrySet() + .stream() + .map(entry -> mapToReviewsGatheredByQuestion(entry.getKey(), entry.getValue())) + .toList(); + + return new ReviewsGatheredBySectionResponse(reviews); + } + + private ReviewsGatheredByQuestionResponse mapToReviewsGatheredByQuestion(Question question, List answers) { + return new ReviewsGatheredByQuestionResponse( + new SimpleQuestionResponse(question.getId(), question.getContent(), question.getQuestionType()), + mapToTextResponse(question, answers), + mapToVoteResponse(question, answers) + ); + } + + @Nullable + private List mapToTextResponse(Question question, List answers) { + if (question.isSelectable()) { + return null; + } + + List textAnswers = castAllOrThrow(answers, TextAnswer.class); + return textAnswers.stream() + .map(textAnswer -> new TextResponse(textAnswer.getId(), textAnswer.getContent(), List.of())) + .toList(); + } + + @Nullable + private List mapToVoteResponse(Question question, List answers) { + if (!question.isSelectable()) { + return null; + } + + List checkboxAnswers = castAllOrThrow(answers, CheckboxAnswer.class); + Map optionItemIdVoteCount = checkboxAnswers.stream() + .flatMap(checkboxAnswer -> checkboxAnswer.getSelectedOptionIds().stream()) + .collect(Collectors.groupingBy(CheckboxAnswerSelectedOption::getSelectedOptionId, + Collectors.counting())); + + List allOptionItem = questionRepository.findAllOptionItemsByIdOrderByPosition(question.getId()); + return allOptionItem.stream() + .map(optionItem -> new VoteResponse( + optionItem.getContent(), + optionItemIdVoteCount.getOrDefault(optionItem.getId(), 0L))) + .toList(); + } + + private List castAllOrThrow(List answers, Class clazz) { + try { + return answers.stream().map(clazz::cast).toList(); + } catch (Exception ex) { + throw new GatheredAnswersTypeNonUniformException(ex); + } + } +} diff --git a/backend/src/main/java/reviewme/reviewgroup/domain/ReviewGroup.java b/backend/src/main/java/reviewme/reviewgroup/domain/ReviewGroup.java index 9da094186..ee5205424 100644 --- a/backend/src/main/java/reviewme/reviewgroup/domain/ReviewGroup.java +++ b/backend/src/main/java/reviewme/reviewgroup/domain/ReviewGroup.java @@ -44,12 +44,17 @@ public class ReviewGroup { private long templateId = 1L; public ReviewGroup(String reviewee, String projectName, String reviewRequestCode, String groupAccessCode) { + this(reviewee, projectName, reviewRequestCode, groupAccessCode, 1L); + } + + public ReviewGroup(String reviewee, String projectName, String reviewRequestCode, String groupAccessCode, long templateId) { validateRevieweeLength(reviewee); validateProjectNameLength(projectName); this.reviewee = reviewee; this.projectName = projectName; this.reviewRequestCode = reviewRequestCode; this.groupAccessCode = new GroupAccessCode(groupAccessCode); + this.templateId = templateId; } private void validateRevieweeLength(String reviewee) { diff --git a/backend/src/main/java/reviewme/template/repository/SectionRepository.java b/backend/src/main/java/reviewme/template/repository/SectionRepository.java index bcb36c92f..ae0aff9f6 100644 --- a/backend/src/main/java/reviewme/template/repository/SectionRepository.java +++ b/backend/src/main/java/reviewme/template/repository/SectionRepository.java @@ -1,6 +1,7 @@ package reviewme.template.repository; import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; @@ -17,4 +18,12 @@ public interface SectionRepository extends JpaRepository { ORDER BY s.position ASC """, nativeQuery = true) List
findAllByTemplateId(long templateId); + + @Query(""" + SELECT s FROM Section s + JOIN TemplateSection ts ON s.id = ts.sectionId + WHERE ts.sectionId = :sectionId + AND ts.templateId = :templateId + """) + Optional
findByIdAndTemplateId(long sectionId, long templateId); } diff --git a/backend/src/test/java/reviewme/api/ApiTest.java b/backend/src/test/java/reviewme/api/ApiTest.java index 2d919e645..463e5f08e 100644 --- a/backend/src/test/java/reviewme/api/ApiTest.java +++ b/backend/src/test/java/reviewme/api/ApiTest.java @@ -29,7 +29,7 @@ import reviewme.highlight.controller.HighlightController; import reviewme.highlight.service.HighlightService; import reviewme.review.controller.ReviewController; -import reviewme.review.service.GatheredReviewLookupService; +import reviewme.review.service.ReviewGatheredLookupService; import reviewme.review.service.ReviewDetailLookupService; import reviewme.review.service.ReviewListLookupService; import reviewme.review.service.ReviewRegisterService; @@ -79,7 +79,7 @@ public abstract class ApiTest { protected SectionService sectionService; @MockBean - protected GatheredReviewLookupService gatheredReviewLookupService; + protected ReviewGatheredLookupService reviewGatheredLookupService; @MockBean protected HighlightService highlightService; diff --git a/backend/src/test/java/reviewme/api/ReviewApiTest.java b/backend/src/test/java/reviewme/api/ReviewApiTest.java index e8ae73a08..06a39f63b 100644 --- a/backend/src/test/java/reviewme/api/ReviewApiTest.java +++ b/backend/src/test/java/reviewme/api/ReviewApiTest.java @@ -24,13 +24,15 @@ import org.springframework.restdocs.request.ParameterDescriptor; import reviewme.question.domain.QuestionType; import reviewme.review.service.dto.request.ReviewRegisterRequest; -import reviewme.review.service.dto.response.gathered.AnswerContentResponse; +import reviewme.review.service.dto.response.gathered.HighlightResponse; +import reviewme.review.service.dto.response.gathered.RangeResponse; import reviewme.review.service.dto.response.gathered.ReviewsGatheredByQuestionResponse; import reviewme.review.service.dto.response.gathered.ReviewsGatheredBySectionResponse; import reviewme.review.service.dto.response.gathered.SimpleQuestionResponse; +import reviewme.review.service.dto.response.gathered.TextResponse; import reviewme.review.service.dto.response.gathered.VoteResponse; -import reviewme.review.service.dto.response.list.ReceivedReviewsSummaryResponse; import reviewme.review.service.dto.response.list.ReceivedReviewsResponse; +import reviewme.review.service.dto.response.list.ReceivedReviewsSummaryResponse; import reviewme.review.service.dto.response.list.ReviewCategoryResponse; import reviewme.review.service.dto.response.list.ReviewListElementResponse; import reviewme.review.service.exception.ReviewGroupNotFoundByReviewRequestCodeException; @@ -254,19 +256,22 @@ class ReviewApiTest extends ApiTest { void 자신이_받은_리뷰의_요약를_섹션별로_조회한다() { ReviewsGatheredBySectionResponse response = new ReviewsGatheredBySectionResponse(List.of( new ReviewsGatheredByQuestionResponse( - new SimpleQuestionResponse("서술형 질문", QuestionType.TEXT), + new SimpleQuestionResponse(1L, "서술형 질문", QuestionType.TEXT), List.of( - new AnswerContentResponse("산초의 답변"), - new AnswerContentResponse("삼촌의 답변")), + new TextResponse(1L, "산초의 답변", List.of( + new HighlightResponse(1, List.of(new RangeResponse(1, 10))), + new HighlightResponse(2, List.of(new RangeResponse(1, 4))) + )), + new TextResponse(2L, "삼촌의 답변", List.of())), null), new ReviewsGatheredByQuestionResponse( - new SimpleQuestionResponse("선택형 질문", QuestionType.CHECKBOX), + new SimpleQuestionResponse(2L, "선택형 질문", QuestionType.CHECKBOX), null, List.of( new VoteResponse("짜장", 3), new VoteResponse("짬뽕", 5)))) ); - BDDMockito.given(gatheredReviewLookupService.getReceivedReviewsBySectionId(anyString(), anyLong())) + BDDMockito.given(reviewGatheredLookupService.getReceivedReviewsBySectionId(anyString(), anyLong())) .willReturn(response); CookieDescriptor[] cookieDescriptors = { @@ -278,11 +283,20 @@ class ReviewApiTest extends ApiTest { FieldDescriptor[] responseFieldDescriptors = { fieldWithPath("reviews").description("리뷰 목록"), fieldWithPath("reviews[].question").description("질문 정보"), + fieldWithPath("reviews[].question.id").description("질문 ID"), fieldWithPath("reviews[].question.name").description("질문 이름"), fieldWithPath("reviews[].question.type").description("질문 유형"), fieldWithPath("reviews[].answers").description("서술형 답변 목록 - question.type이 TEXT가 아니면 null").optional(), + fieldWithPath("reviews[].answers[].id").description("답변 ID").optional(), fieldWithPath("reviews[].answers[].content").description("서술형 답변 내용"), - fieldWithPath("reviews[].votes").description("객관식 답변 목록 - question.type이 CHECKBOX가 아니면 null").optional(), + fieldWithPath("reviews[].answers[].highlights").description("형광펜 정보"), + fieldWithPath("reviews[].answers[].highlights[].lineIndex").description("개행으로 구분되는 라인 번호, 0-based"), + fieldWithPath("reviews[].answers[].highlights[].ranges").description("형광펜 범위"), + fieldWithPath("reviews[].answers[].highlights[].ranges[].startIndex").description( + "하이라이트 시작 인덱스, 0-based"), + fieldWithPath("reviews[].answers[].highlights[].ranges[].endIndex").description("하이라이트 끝 인덱스, 0-based"), + fieldWithPath("reviews[].votes").description( + "객관식 답변 목록 - question.type이 CHECKBOX가 아니면 null").optional(), fieldWithPath("reviews[].votes[].content").description("객관식 항목"), fieldWithPath("reviews[].votes[].count").description("선택한 사람 수"), }; diff --git a/backend/src/test/java/reviewme/question/repository/QuestionRepositoryTest.java b/backend/src/test/java/reviewme/question/repository/QuestionRepositoryTest.java index e0d427558..da694e335 100644 --- a/backend/src/test/java/reviewme/question/repository/QuestionRepositoryTest.java +++ b/backend/src/test/java/reviewme/question/repository/QuestionRepositoryTest.java @@ -1,7 +1,10 @@ package reviewme.question.repository; import static org.assertj.core.api.Assertions.assertThat; +import static reviewme.fixture.OptionGroupFixture.선택지_그룹; +import static reviewme.fixture.OptionItemFixture.선택지; import static reviewme.fixture.QuestionFixture.서술형_필수_질문; +import static reviewme.fixture.QuestionFixture.선택형_필수_질문; import static reviewme.fixture.SectionFixture.항상_보이는_섹션; import static reviewme.fixture.TemplateFixture.템플릿; @@ -10,7 +13,11 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import reviewme.question.domain.OptionGroup; +import reviewme.question.domain.OptionItem; import reviewme.question.domain.Question; +import reviewme.reviewgroup.domain.ReviewGroup; +import reviewme.reviewgroup.repository.ReviewGroupRepository; import reviewme.template.domain.Section; import reviewme.template.domain.Template; import reviewme.template.repository.SectionRepository; @@ -28,6 +35,15 @@ class QuestionRepositoryTest { @Autowired private TemplateRepository templateRepository; + @Autowired + private ReviewGroupRepository reviewGroupRepository; + + @Autowired + private OptionGroupRepository optionGroupRepository; + + @Autowired + private OptionItemRepository optionItemRepository; + @Test void 템플릿_아이디로_질문_목록_아이디를_모두_가져온다() { // given @@ -71,4 +87,49 @@ class QuestionRepositoryTest { // then assertThat(actual).containsExactlyInAnyOrder(question1, question2); } + + @Test + void 섹션_아이디에_해당하는_질문을_순서대로_가져온다() { + // given + Question question1 = questionRepository.save(서술형_필수_질문(1)); + Question question2 = questionRepository.save(서술형_필수_질문(2)); + Question question3 = questionRepository.save(서술형_필수_질문(3)); + Question question4 = questionRepository.save(서술형_필수_질문(1)); + + List sectionQuestion1 = List.of(question1.getId(), question2.getId(), question3.getId()); + List sectionQuestion2 = List.of(question4.getId()); + Section section1 = sectionRepository.save(항상_보이는_섹션(sectionQuestion1)); + Section section2 = sectionRepository.save(항상_보이는_섹션(sectionQuestion2)); + Template template = templateRepository.save(템플릿(List.of(section1.getId(), section2.getId()))); + + ReviewGroup reviewGroup = reviewGroupRepository.save(new ReviewGroup( + "reviewee", "projectName", "reviewRequestCode", "groupAccessCode", template.getId() + )); + + // when + List questionsInSection = questionRepository.findAllBySectionIdOrderByPosition(section1.getId()); + + // then + assertThat(questionsInSection).containsExactly(question1, question2, question3); + } + + @Test + void 질문_아이디에_해당하는_모든_옵션_아이템을_순서대로_불러온다() { + // given + Question question1 = questionRepository.save(선택형_필수_질문()); + Question question2 = questionRepository.save(선택형_필수_질문()); + OptionGroup optionGroup1 = optionGroupRepository.save(선택지_그룹(question1.getId())); + OptionGroup optionGroup2 = optionGroupRepository.save(선택지_그룹(question2.getId())); + + OptionItem optionItem1 = optionItemRepository.save(선택지(optionGroup1.getId())); + OptionItem optionItem2 = optionItemRepository.save(선택지(optionGroup1.getId())); + OptionItem optionItem3 = optionItemRepository.save(선택지(optionGroup2.getId())); + + // when + List optionItemsForQuestion1 + = questionRepository.findAllOptionItemsByIdOrderByPosition(question1.getId()); + + // then + assertThat(optionItemsForQuestion1).containsExactly(optionItem1, optionItem2); + } } diff --git a/backend/src/test/java/reviewme/review/repository/AnswerRepositoryTest.java b/backend/src/test/java/reviewme/review/repository/AnswerRepositoryTest.java index bd76f054d..e13ce1427 100644 --- a/backend/src/test/java/reviewme/review/repository/AnswerRepositoryTest.java +++ b/backend/src/test/java/reviewme/review/repository/AnswerRepositoryTest.java @@ -1,39 +1,80 @@ package reviewme.review.repository; import static org.assertj.core.api.Assertions.assertThat; +import static reviewme.fixture.QuestionFixture.서술형_필수_질문; +import static reviewme.fixture.ReviewGroupFixture.리뷰_그룹; +import static reviewme.fixture.SectionFixture.항상_보이는_섹션; +import static reviewme.fixture.TemplateFixture.템플릿; import java.util.List; import java.util.Set; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; -import reviewme.fixture.QuestionFixture; -import reviewme.fixture.ReviewGroupFixture; +import reviewme.question.domain.Question; import reviewme.question.repository.QuestionRepository; +import reviewme.review.domain.Answer; import reviewme.review.domain.Review; import reviewme.review.domain.TextAnswer; import reviewme.reviewgroup.domain.ReviewGroup; import reviewme.reviewgroup.repository.ReviewGroupRepository; +import reviewme.template.domain.Section; +import reviewme.template.domain.Template; +import reviewme.template.repository.SectionRepository; +import reviewme.template.repository.TemplateRepository; @DataJpaTest class AnswerRepositoryTest { @Autowired - AnswerRepository answerRepository; + private AnswerRepository answerRepository; @Autowired - ReviewGroupRepository reviewGroupRepository; + private QuestionRepository questionRepository; @Autowired - ReviewRepository reviewRepository; + private SectionRepository sectionRepository; @Autowired - QuestionRepository questionRepository; + private TemplateRepository templateRepository; + + @Autowired + private ReviewGroupRepository reviewGroupRepository; + + @Autowired + private ReviewRepository reviewRepository; + + @Test + void 내가_받은_답변들_중_주어진_질문들에_대한_답변들을_최신_작성순으로_제한된_수만_반환한다() { + // given + Question question1 = questionRepository.save(서술형_필수_질문()); + Question question2 = questionRepository.save(서술형_필수_질문()); + Question question3 = questionRepository.save(서술형_필수_질문()); + Section section = sectionRepository.save(항상_보이는_섹션( + List.of(question1.getId(), question2.getId(), question3.getId()))); + Template template = templateRepository.save(템플릿(List.of(section.getId()))); + ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹()); + + TextAnswer answer1 = new TextAnswer(question1.getId(), "답1".repeat(20)); + TextAnswer answer2 = new TextAnswer(question2.getId(), "답2".repeat(20)); + TextAnswer answer3 = new TextAnswer(question2.getId(), "답3".repeat(20)); + TextAnswer answer4 = new TextAnswer(question3.getId(), "답4".repeat(20)); + reviewRepository.save(new Review(template.getId(), reviewGroup.getId(), List.of(answer1))); + reviewRepository.save(new Review(template.getId(), reviewGroup.getId(), List.of(answer2))); + reviewRepository.save(new Review(template.getId(), reviewGroup.getId(), List.of(answer3))); + + // when + List actual = answerRepository.findReceivedAnswersByQuestionIds( + reviewGroup.getId(), List.of(question1.getId(), question2.getId()), 2); + + // then + assertThat(actual).containsExactly(answer3, answer2); + } @Test void 리뷰_그룹_id로_리뷰들을_찾아_id를_반환한다() { // given - ReviewGroup reviewGroup = reviewGroupRepository.save(ReviewGroupFixture.리뷰_그룹()); + ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹()); TextAnswer answer1 = new TextAnswer(1L, "text answer1"); TextAnswer answer2 = new TextAnswer(1L, "text answer2"); Review review = reviewRepository.save(new Review(1L, reviewGroup.getId(), List.of(answer1, answer2))); @@ -48,9 +89,9 @@ class AnswerRepositoryTest { @Test void 질문_id로_리뷰들을_찾아_id를_반환한다() { // given - ReviewGroup reviewGroup = reviewGroupRepository.save(ReviewGroupFixture.리뷰_그룹()); - long questionId1 = questionRepository.save(QuestionFixture.서술형_필수_질문()).getId(); - long questionId2 = questionRepository.save(QuestionFixture.서술형_필수_질문()).getId(); + ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹()); + long questionId1 = questionRepository.save(서술형_필수_질문()).getId(); + long questionId2 = questionRepository.save(서술형_필수_질문()).getId(); TextAnswer textAnswer1_Q1 = new TextAnswer(questionId1, "text answer1 by Q1"); TextAnswer textAnswer2_Q1 = new TextAnswer(questionId1, "text answer2 by Q1"); TextAnswer textAnswer1_Q2 = new TextAnswer(questionId2, "text answer1 by Q2"); diff --git a/backend/src/test/java/reviewme/review/service/ReviewGatheredLookupServiceTest.java b/backend/src/test/java/reviewme/review/service/ReviewGatheredLookupServiceTest.java new file mode 100644 index 000000000..7a378adb5 --- /dev/null +++ b/backend/src/test/java/reviewme/review/service/ReviewGatheredLookupServiceTest.java @@ -0,0 +1,395 @@ +package reviewme.review.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; +import static reviewme.fixture.OptionGroupFixture.선택지_그룹; +import static reviewme.fixture.OptionItemFixture.선택지; +import static reviewme.fixture.QuestionFixture.서술형_옵션_질문; +import static reviewme.fixture.QuestionFixture.서술형_필수_질문; +import static reviewme.fixture.QuestionFixture.선택형_옵션_질문; +import static reviewme.fixture.QuestionFixture.선택형_필수_질문; +import static reviewme.fixture.ReviewGroupFixture.리뷰_그룹; +import static reviewme.fixture.SectionFixture.항상_보이는_섹션; +import static reviewme.fixture.TemplateFixture.템플릿; + +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import reviewme.question.domain.OptionGroup; +import reviewme.question.domain.OptionItem; +import reviewme.question.domain.OptionType; +import reviewme.question.domain.Question; +import reviewme.question.repository.OptionGroupRepository; +import reviewme.question.repository.OptionItemRepository; +import reviewme.question.repository.QuestionRepository; +import reviewme.review.domain.CheckboxAnswer; +import reviewme.review.domain.Review; +import reviewme.review.domain.TextAnswer; +import reviewme.review.repository.ReviewRepository; +import reviewme.review.service.dto.response.gathered.ReviewsGatheredByQuestionResponse; +import reviewme.review.service.dto.response.gathered.ReviewsGatheredBySectionResponse; +import reviewme.review.service.dto.response.gathered.SimpleQuestionResponse; +import reviewme.review.service.dto.response.gathered.TextResponse; +import reviewme.review.service.dto.response.gathered.VoteResponse; +import reviewme.reviewgroup.domain.ReviewGroup; +import reviewme.reviewgroup.repository.ReviewGroupRepository; +import reviewme.support.ServiceTest; +import reviewme.template.domain.Section; +import reviewme.template.domain.Template; +import reviewme.template.repository.SectionRepository; +import reviewme.template.repository.TemplateRepository; + +@ServiceTest +class ReviewGatheredLookupServiceTest { + + @Autowired + private ReviewGroupRepository reviewGroupRepository; + + @Autowired + private ReviewRepository reviewRepository; + + @Autowired + private SectionRepository sectionRepository; + + @Autowired + private QuestionRepository questionRepository; + + @Autowired + private OptionGroupRepository optionGroupRepository; + + @Autowired + private OptionItemRepository optionItemRepository; + + @Autowired + private TemplateRepository templateRepository; + + @Autowired + private ReviewGatheredLookupService reviewLookupService; + + private String reviewRequestCode; + private ReviewGroup reviewGroup; + + @BeforeEach + void saveReviewGroup() { + reviewRequestCode = "1111"; + reviewGroup = reviewGroupRepository.save(리뷰_그룹(reviewRequestCode, "2222")); + } + + @Nested + @DisplayName("섹션에 해당하는 서술형 응답을 질문별로 묶어 반환한다") + class GatherAnswerByQuestionTest { + + @Test + void 섹션_하위_질문이_하나인_경우() { + // given - 질문 저장 + Question question1 = questionRepository.save(서술형_필수_질문()); + + // given - 섹션, 템플릿 저장 + Section section1 = sectionRepository.save(항상_보이는_섹션(List.of(question1.getId()))); + Template template = templateRepository.save(템플릿(List.of(section1.getId()))); + + // given - 리뷰 답변 저장 + TextAnswer answerKB = new TextAnswer(question1.getId(), "커비가 작성한 서술형 답변1"); + TextAnswer answerSC = new TextAnswer(question1.getId(), "산초가 작성한 서술형 답변1"); + reviewRepository.save(new Review(template.getId(), reviewGroup.getId(), List.of(answerKB))); + reviewRepository.save(new Review(template.getId(), reviewGroup.getId(), List.of(answerSC))); + + // when + ReviewsGatheredBySectionResponse actual = reviewLookupService.getReceivedReviewsBySectionId( + reviewRequestCode, section1.getId()); + + // then + assertThat(actual.reviews().get(0).answers()).extracting(TextResponse::content) + .containsOnly("커비가 작성한 서술형 답변1", "산초가 작성한 서술형 답변1"); + } + + @Test + void 섹션_하위_질문이_여러개인_경우() { + // given - 질문 저장 + Question question1 = questionRepository.save(서술형_필수_질문()); + Question question2 = questionRepository.save(서술형_필수_질문()); + + // given - 섹션, 템플릿 저장 + Section section1 = sectionRepository.save(항상_보이는_섹션(List.of(question1.getId(), question2.getId()))); + Template template = templateRepository.save(템플릿(List.of(section1.getId()))); + + // given - 리뷰 답변 저장 + TextAnswer answerAR1 = new TextAnswer(question1.getId(), "아루가 작성한 서술형 답변1"); + TextAnswer answerAR2 = new TextAnswer(question2.getId(), "아루가 작성한 서술형 답변2"); + TextAnswer answerTD1 = new TextAnswer(question1.getId(), "테드가 작성한 서술형 답변1"); + TextAnswer answerTD2 = new TextAnswer(question2.getId(), "테드가 작성한 서술형 답변2"); + reviewRepository.save(new Review(template.getId(), reviewGroup.getId(), List.of(answerAR1, answerAR2))); + reviewRepository.save(new Review(template.getId(), reviewGroup.getId(), List.of(answerTD1, answerTD2))); + + // when + ReviewsGatheredBySectionResponse actual = reviewLookupService.getReceivedReviewsBySectionId( + reviewRequestCode, section1.getId()); + + // then + assertThat(actual.reviews().get(0).answers()) + .extracting(TextResponse::content) + .containsExactlyInAnyOrder("아루가 작성한 서술형 답변1", "테드가 작성한 서술형 답변1"); + assertThat(actual.reviews().get(1).answers()) + .extracting(TextResponse::content) + .containsExactlyInAnyOrder("아루가 작성한 서술형 답변2", "테드가 작성한 서술형 답변2"); + } + + @Test + void 여러개의_섹션이_있는_경우_주어진_섹션ID에_해당하는_것만_반환한다() { + // given - 질문 저장 + Question question1 = questionRepository.save(서술형_필수_질문()); + Question question2 = questionRepository.save(서술형_필수_질문()); + + // given - 섹션, 템플릿 저장 + Section section1 = sectionRepository.save(항상_보이는_섹션(List.of(question1.getId()))); + Section section2 = sectionRepository.save(항상_보이는_섹션(List.of(question2.getId()))); + Template template = templateRepository.save(템플릿(List.of(section1.getId(), section2.getId()))); + + // given - 리뷰 답변 저장 + TextAnswer answerAR1 = new TextAnswer(question1.getId(), "아루가 작성한 서술형 답변1"); + TextAnswer answerAR2 = new TextAnswer(question2.getId(), "아루가 작성한 서술형 답변2"); + TextAnswer answerTD1 = new TextAnswer(question1.getId(), "테드가 작성한 서술형 답변1"); + TextAnswer answerTD2 = new TextAnswer(question2.getId(), "테드가 작성한 서술형 답변2"); + reviewRepository.save(new Review(template.getId(), reviewGroup.getId(), List.of(answerAR1, answerAR2))); + reviewRepository.save(new Review(template.getId(), reviewGroup.getId(), List.of(answerTD1, answerTD2))); + + // when + ReviewsGatheredBySectionResponse actual = reviewLookupService.getReceivedReviewsBySectionId( + reviewRequestCode, section1.getId()); + + // then + assertThat(actual.reviews().get(0).answers()) + .extracting(TextResponse::content) + .containsExactlyInAnyOrder("아루가 작성한 서술형 답변1", "테드가 작성한 서술형 답변1"); + } + + @Test + void 섹션에_필수가_아닌_질문이_있는_경우_답변된_내용만_반환한다() { + // given - 질문 저장 + Question question1 = questionRepository.save(서술형_옵션_질문()); + Question question2 = questionRepository.save(서술형_옵션_질문()); + + // given - 섹션, 템플릿 저장 + Section section1 = sectionRepository.save(항상_보이는_섹션(List.of(question1.getId(), question2.getId()))); + Template template = templateRepository.save(템플릿(List.of(section1.getId()))); + + // given - 리뷰 답변 저장 + TextAnswer answerSC1 = new TextAnswer(question1.getId(), "산초가 작성한 서술형 답변1"); + TextAnswer answerSC2 = new TextAnswer(question2.getId(), "산초가 작성한 서술형 답변2"); + TextAnswer answerAR = new TextAnswer(question1.getId(), "아루가 작성한 서술형 답변"); + reviewRepository.save(new Review(template.getId(), reviewGroup.getId(), List.of(answerSC1, answerSC2))); + reviewRepository.save(new Review(template.getId(), reviewGroup.getId(), List.of(answerAR))); + + // when + ReviewsGatheredBySectionResponse actual = reviewLookupService.getReceivedReviewsBySectionId( + reviewRequestCode, section1.getId()); + + // then + assertThat(actual.reviews().get(0).answers()) + .extracting(TextResponse::content) + .containsExactlyInAnyOrder("산초가 작성한 서술형 답변1", "아루가 작성한 서술형 답변"); + assertThat(actual.reviews().get(1).answers()) + .extracting(TextResponse::content) + .containsExactly("산초가 작성한 서술형 답변2"); + } + + @Test + void 질문에_응답이_없는_경우_질문_내용은_반환하되_응답은_빈_배열로_반환한다() { + // given - 질문 저장 + Question question1 = questionRepository.save(서술형_필수_질문()); + + // given - 섹션, 템플릿 저장 + Section section1 = sectionRepository.save(항상_보이는_섹션(List.of(question1.getId()))); + Template template = templateRepository.save(템플릿(List.of(section1.getId()))); + + // when + ReviewsGatheredBySectionResponse actual = reviewLookupService.getReceivedReviewsBySectionId( + reviewRequestCode, section1.getId()); + + // then + assertThat(actual.reviews()).hasSize(1); + assertThat(actual.reviews().get(0).question().name()).isEqualTo(question1.getContent()); + assertThat(actual.reviews().get(0).answers()).isEmpty(); + assertThat(actual.reviews().get(0).votes()).isNull(); + } + } + + @Nested + @DisplayName("섹션에 해당하는 선택형 응답을 질문별로 묶고, 선택된 횟수를 계산하여 반환한다") + class GatherOptionAnswerByQuestionTest { + + @Test + void 섹션_하위_질문이_하나인_경우() { + // given - 질문 저장 + Question question1 = questionRepository.save(선택형_필수_질문()); + OptionGroup optionGroup = optionGroupRepository.save(선택지_그룹(question1.getId())); + OptionItem optionItem1 = optionItemRepository.save( + new OptionItem("짜장", optionGroup.getId(), 1, OptionType.CATEGORY)); + OptionItem optionItem2 = optionItemRepository.save( + new OptionItem("짬뽕", optionGroup.getId(), 2, OptionType.CATEGORY)); + + // given - 섹션, 템플릿 저장 + Section section1 = sectionRepository.save(항상_보이는_섹션(List.of(question1.getId()))); + Template template = templateRepository.save(템플릿(List.of(section1.getId()))); + + // given - 리뷰 답변 저장 + CheckboxAnswer answer1 = new CheckboxAnswer(question1.getId(), List.of(optionItem1.getId())); + CheckboxAnswer answer2 = new CheckboxAnswer(question1.getId(), + List.of(optionItem1.getId(), optionItem2.getId())); + reviewRepository.save(new Review(template.getId(), reviewGroup.getId(), List.of(answer1))); + reviewRepository.save(new Review(template.getId(), reviewGroup.getId(), List.of(answer2))); + + // when + ReviewsGatheredBySectionResponse actual = reviewLookupService.getReceivedReviewsBySectionId( + reviewRequestCode, section1.getId()); + + // then + assertThat(actual.reviews().get(0).votes()) + .extracting(VoteResponse::content, VoteResponse::count) + .containsExactlyInAnyOrder( + tuple("짜장", 2L), + tuple("짬뽕", 1L) + ); + } + + @Test + void 섹션_하위_질문이_여러개인_경우() { + // given - 질문 저장 + Question question1 = questionRepository.save(선택형_옵션_질문()); + Question question2 = questionRepository.save(선택형_옵션_질문()); + OptionGroup optionGroup1 = optionGroupRepository.save(선택지_그룹(question1.getId())); + OptionGroup optionGroup2 = optionGroupRepository.save(선택지_그룹(question2.getId())); + OptionItem optionItem1 = optionItemRepository.save( + new OptionItem("중식", optionGroup1.getId(), 1, OptionType.CATEGORY)); + OptionItem optionItem2 = optionItemRepository.save( + new OptionItem("분식", optionGroup2.getId(), 2, OptionType.CATEGORY)); + + // given - 섹션, 템플릿 저장 + Section section1 = sectionRepository.save(항상_보이는_섹션(List.of(question1.getId(), question2.getId()))); + Template template = templateRepository.save(템플릿(List.of(section1.getId()))); + + // given - 리뷰 답변 저장 + CheckboxAnswer answer1 = new CheckboxAnswer(question1.getId(), List.of(optionItem1.getId())); + CheckboxAnswer answer2 = new CheckboxAnswer(question2.getId(), List.of(optionItem2.getId())); + reviewRepository.save(new Review(template.getId(), reviewGroup.getId(), List.of(answer1, answer2))); + + // when + ReviewsGatheredBySectionResponse actual = reviewLookupService.getReceivedReviewsBySectionId( + reviewRequestCode, section1.getId()); + + // then + assertThat(actual.reviews().get(0).votes()) + .extracting(VoteResponse::content, VoteResponse::count) + .containsOnly(tuple("중식", 1L)); + assertThat(actual.reviews().get(1).votes()) + .extracting(VoteResponse::content, VoteResponse::count) + .containsOnly(tuple("분식", 1L)); + } + + @Test + void 아무도_고르지_않은_선택지는_0개로_계산하여_반환한다() { + // given - 질문 저장 + Question question1 = questionRepository.save(선택형_필수_질문()); + OptionGroup optionGroup = optionGroupRepository.save(선택지_그룹(question1.getId())); + OptionItem optionItem1 = optionItemRepository.save( + new OptionItem("우테코 산초", optionGroup.getId(), 1, OptionType.CATEGORY)); + OptionItem optionItem2 = optionItemRepository.save( + new OptionItem("제이든 산초", optionGroup.getId(), 2, OptionType.CATEGORY)); + + // given - 섹션, 템플릿 저장 + Section section1 = sectionRepository.save(항상_보이는_섹션(List.of(question1.getId()))); + Template template = templateRepository.save(템플릿(List.of(section1.getId()))); + + // given - 리뷰 답변 저장 + CheckboxAnswer answer1 = new CheckboxAnswer(question1.getId(), List.of(optionItem1.getId())); + CheckboxAnswer answer2 = new CheckboxAnswer(question1.getId(), List.of(optionItem1.getId())); + reviewRepository.save(new Review(template.getId(), reviewGroup.getId(), List.of(answer1))); + reviewRepository.save(new Review(template.getId(), reviewGroup.getId(), List.of(answer2))); + + // when + ReviewsGatheredBySectionResponse actual = reviewLookupService.getReceivedReviewsBySectionId( + reviewRequestCode, section1.getId()); + + // then + assertThat(actual.reviews().get(0).votes()) + .extracting(VoteResponse::content, VoteResponse::count) + .containsExactlyInAnyOrder( + tuple("우테코 산초", 2L), + tuple("제이든 산초", 0L) + ); + } + } + + @Test + void 서술형_질문에_대한_응답과_선택형_질문에_대한_응답을_함께_반환한다() { + // given - 질문 저장 + Question question1 = questionRepository.save(서술형_필수_질문()); + Question question2 = questionRepository.save(선택형_필수_질문()); + OptionGroup optionGroup = optionGroupRepository.save(선택지_그룹(question2.getId())); + OptionItem optionItem1 = optionItemRepository.save(선택지(optionGroup.getId())); + OptionItem optionItem2 = optionItemRepository.save(선택지(optionGroup.getId())); + + // given - 섹션, 템플릿 저장 + Section section1 = sectionRepository.save(항상_보이는_섹션(List.of(question1.getId(), question2.getId()))); + Template template = templateRepository.save(템플릿(List.of(section1.getId()))); + + // given - 리뷰 답변 저장 + TextAnswer answer1 = new TextAnswer(question1.getId(), "아루가 작성한 서술형 답변"); + CheckboxAnswer answer2 = new CheckboxAnswer(question2.getId(), + List.of(optionItem1.getId(), optionItem2.getId())); + reviewRepository.save(new Review(template.getId(), reviewGroup.getId(), List.of(answer1, answer2))); + + // when + ReviewsGatheredBySectionResponse actual = reviewLookupService.getReceivedReviewsBySectionId( + reviewRequestCode, section1.getId()); + + // then + assertThat(actual.reviews()).hasSize(2); + assertThat(actual.reviews()) + .extracting(ReviewsGatheredByQuestionResponse::question) + .extracting(SimpleQuestionResponse::name) + .containsOnly(question1.getContent(), question2.getContent()); + assertThat(actual.reviews().get(0).answers()) + .extracting(TextResponse::content) + .containsExactly("아루가 작성한 서술형 답변"); + assertThat(actual.reviews().get(0).votes()).isNull(); + assertThat(actual.reviews().get(1).votes()) + .extracting(VoteResponse::content, VoteResponse::count) + .containsExactlyInAnyOrder( + tuple(optionItem1.getContent(), 1L), + tuple(optionItem2.getContent(), 1L) + ); + assertThat(actual.reviews().get(1).answers()).isNull(); + } + + @Test + void 다른_사람이_받은_리뷰는_포함하지_않는다() { + // given - 질문 저장 + Question question1 = questionRepository.save(서술형_필수_질문()); + Section section1 = sectionRepository.save(항상_보이는_섹션(List.of(question1.getId()))); + Template template = templateRepository.save(템플릿(List.of(section1.getId()))); + + String reviewRequestCodeBE = "review_me_be"; + ReviewGroup reviewGroupBE = new ReviewGroup("reviewee", "projectName", + reviewRequestCodeBE, "groupAccessCode", template.getId()); + ReviewGroup reviewGroupFE = new ReviewGroup("reviewee", "projectName", + "reviewRequestCode", "groupAccessCode", template.getId()); + reviewGroupRepository.saveAll(List.of(reviewGroupFE, reviewGroupBE)); + + // given - 리뷰 답변 저장 + TextAnswer answerFE = new TextAnswer(question1.getId(), "프론트엔드가 작성한 서술형 답변"); + TextAnswer answerBE = new TextAnswer(question1.getId(), "백엔드가 작성한 서술형 답변"); + reviewRepository.save(new Review(template.getId(), reviewGroupFE.getId(), List.of(answerFE))); + reviewRepository.save(new Review(template.getId(), reviewGroupBE.getId(), List.of(answerBE))); + + // when + ReviewsGatheredBySectionResponse actual = reviewLookupService.getReceivedReviewsBySectionId( + reviewRequestCodeBE, section1.getId()); + + // then + assertThat(actual.reviews()).hasSize(1); + } +} diff --git a/backend/src/test/java/reviewme/review/service/mapper/ReviewGatherMapperTest.java b/backend/src/test/java/reviewme/review/service/mapper/ReviewGatherMapperTest.java new file mode 100644 index 000000000..fdac6e0c3 --- /dev/null +++ b/backend/src/test/java/reviewme/review/service/mapper/ReviewGatherMapperTest.java @@ -0,0 +1,137 @@ +package reviewme.review.service.mapper; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static reviewme.fixture.OptionGroupFixture.선택지_그룹; +import static reviewme.fixture.OptionItemFixture.선택지; +import static reviewme.fixture.QuestionFixture.서술형_옵션_질문; +import static reviewme.fixture.QuestionFixture.선택형_옵션_질문; + +import java.util.List; +import java.util.Map; +import org.assertj.core.groups.Tuple; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import reviewme.question.domain.OptionGroup; +import reviewme.question.domain.OptionItem; +import reviewme.question.domain.Question; +import reviewme.question.repository.OptionGroupRepository; +import reviewme.question.repository.OptionItemRepository; +import reviewme.question.repository.QuestionRepository; +import reviewme.review.domain.CheckboxAnswer; +import reviewme.review.domain.Review; +import reviewme.review.domain.TextAnswer; +import reviewme.review.repository.ReviewRepository; +import reviewme.review.service.dto.response.gathered.ReviewsGatheredByQuestionResponse; +import reviewme.review.service.dto.response.gathered.ReviewsGatheredBySectionResponse; +import reviewme.review.service.dto.response.gathered.SimpleQuestionResponse; +import reviewme.review.service.dto.response.gathered.TextResponse; +import reviewme.review.service.dto.response.gathered.VoteResponse; +import reviewme.support.ServiceTest; +import reviewme.template.repository.SectionRepository; + +@ServiceTest +class ReviewGatherMapperTest { + + @Autowired + private ReviewGatherMapper reviewGatherMapper; + + @Autowired + private SectionRepository sectionRepository; + + @Autowired + private QuestionRepository questionRepository; + + @Autowired + private OptionGroupRepository optionGroupRepository; + + @Autowired + private OptionItemRepository optionItemRepository; + + @Autowired + private ReviewRepository reviewRepository; + + @Test + void 질문과_하위_답변을_규칙에_맞게_반환한다() { + // given + Question question1 = questionRepository.save(서술형_옵션_질문(1)); + Question question2 = questionRepository.save(선택형_옵션_질문(2)); + OptionGroup optionGroup = optionGroupRepository.save(선택지_그룹(question2.getId())); + OptionItem optionItem1 = optionItemRepository.save(선택지(optionGroup.getId())); + OptionItem optionItem2 = optionItemRepository.save(선택지(optionGroup.getId())); + optionItemRepository.saveAll(List.of(optionItem1, optionItem2)); + + TextAnswer textAnswer1 = new TextAnswer(question1.getId(), "프엔 서술형 답변"); + TextAnswer textAnswer2 = new TextAnswer(question1.getId(), "백엔드 서술형 답변"); + CheckboxAnswer checkboxAnswer = new CheckboxAnswer( + question2.getId(), List.of(optionItem1.getId(), optionItem2.getId())); + reviewRepository.save(new Review(1L, 1L, List.of(textAnswer1, textAnswer2, checkboxAnswer))); + + // when + ReviewsGatheredBySectionResponse actual = reviewGatherMapper.mapToReviewsGatheredBySection(Map.of( + question1, List.of(textAnswer1, textAnswer2), + question2, List.of(checkboxAnswer))); + + // then + assertAll( + () -> 질문의_수만큼_반환한다(actual, 2), + () -> 질문의_내용을_반환한다(actual, question1.getContent(), question2.getContent()), + () -> 서술형_답변을_반환한다(actual, "프엔 서술형 답변", "백엔드 서술형 답변"), + () -> 선택형_답변을_반환한다(actual, + Tuple.tuple(optionItem1.getContent(), 1L), + Tuple.tuple(optionItem2.getContent(), 1L)) + ); + } + + @Test + void 서술형_질문에_답변이_없으면_질문_정보는_반환하되_답변은_빈_배열로_반환한다() { + // given + Question question1 = questionRepository.save(서술형_옵션_질문(1)); + Question question2 = questionRepository.save(서술형_옵션_질문(2)); + + // when + ReviewsGatheredBySectionResponse actual = reviewGatherMapper.mapToReviewsGatheredBySection(Map.of( + question1, List.of(), + question2, List.of())); + + // then + assertAll( + () -> 질문의_수만큼_반환한다(actual, 2), + () -> 질문의_내용을_반환한다(actual, question1.getContent(), question2.getContent()), + () -> assertThat(actual.reviews()) + .flatExtracting(ReviewsGatheredByQuestionResponse::answers) + .isEmpty() + ); + } + + private void 질문의_수만큼_반환한다(ReviewsGatheredBySectionResponse actual, int expectedSize) { + assertThat(actual.reviews()).hasSize(expectedSize); + } + + private void 질문의_내용을_반환한다(ReviewsGatheredBySectionResponse actual, String... expectedContents) { + assertThat(actual.reviews()) + .extracting(ReviewsGatheredByQuestionResponse::question) + .extracting(SimpleQuestionResponse::name) + .containsExactly(expectedContents); + } + + private void 서술형_답변을_반환한다(ReviewsGatheredBySectionResponse actual, String... expectedAnswerContents) { + List textResponse = actual.reviews() + .stream() + .filter(review -> review.answers() != null) + .flatMap(reviewsGatheredByQuestionResponse -> reviewsGatheredByQuestionResponse.answers().stream()) + .toList(); + assertThat(textResponse).extracting(TextResponse::content).containsExactly(expectedAnswerContents); + } + + private void 선택형_답변을_반환한다(ReviewsGatheredBySectionResponse actual, Tuple... expectedVotes) { + List voteResponses = actual.reviews() + .stream() + .filter(review -> review.votes() != null) + .flatMap(reviewsGatheredByQuestionResponse -> reviewsGatheredByQuestionResponse.votes().stream()) + .toList(); + assertThat(voteResponses) + .extracting(VoteResponse::content, VoteResponse::count) + .containsExactly(expectedVotes); + } +} diff --git a/backend/src/test/java/reviewme/template/repository/SectionRepositoryTest.java b/backend/src/test/java/reviewme/template/repository/SectionRepositoryTest.java index 8bfa41dca..41e2699ed 100644 --- a/backend/src/test/java/reviewme/template/repository/SectionRepositoryTest.java +++ b/backend/src/test/java/reviewme/template/repository/SectionRepositoryTest.java @@ -1,10 +1,12 @@ package reviewme.template.repository; import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; import static reviewme.fixture.SectionFixture.항상_보이는_섹션; import static reviewme.fixture.TemplateFixture.템플릿; import java.util.List; +import java.util.Optional; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; @@ -38,4 +40,23 @@ class SectionRepositoryTest { // then assertThat(actual).containsExactly(section1, section2, section3); } + + @Test + void 템플릿_아이디와_섹션_아이디에_해당하는_섹션을_반환한다() { + // given + List questionIds = List.of(1L); + Section section1 = sectionRepository.save(항상_보이는_섹션(questionIds)); + Section section2 = sectionRepository.save(항상_보이는_섹션(questionIds)); + Template template = templateRepository.save(템플릿(List.of(section1.getId()))); + + // when + Optional
actual1 = sectionRepository.findByIdAndTemplateId(section1.getId(), template.getId()); + Optional
actual2 = sectionRepository.findByIdAndTemplateId(section2.getId(), template.getId()); + + // then + assertAll( + () -> assertThat(actual1).isPresent(), + () -> assertThat(actual2).isEmpty() + ); + } } From 9a9d6dee698f74324cf716f8938a268af27bc644 Mon Sep 17 00:00:00 2001 From: Hyeonji <110809927+skylar1220@users.noreply.github.com> Date: Fri, 11 Oct 2024 17:35:49 +0900 Subject: [PATCH 15/49] =?UTF-8?q?[BE]=20fix:=20Highlight=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=EC=9D=98=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=EC=9D=84=20db=20=ED=85=8C=EC=9D=B4=EB=B8=94?= =?UTF-8?q?=EC=99=80=20=EC=9D=BC=EC=B9=98=EC=8B=9C=ED=82=A8=EB=8B=A4.=20(#?= =?UTF-8?q?829)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: db와 데이터 타입 범위 일치를 위해 필드 타입 int을 변경 * refactor: dto의 인덱스 관련 데이터 타입을 int로 변경 * refactor: null을 처리하도록 DTO의 필드 데이터 타입을 Integer로 변경 * refactor: 불필요한 타입 캐스팅 제거 --- .../main/java/reviewme/highlight/domain/Highlight.java | 2 +- .../reviewme/highlight/domain/HighlightPosition.java | 8 ++++---- .../service/dto/HighlightIndexRangeRequest.java | 4 ++-- .../highlight/service/dto/HighlightedLineRequest.java | 2 +- .../highlight/service/HighlightServiceTest.java | 10 +++++----- .../service/validator/HighlightValidatorTest.java | 2 +- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/backend/src/main/java/reviewme/highlight/domain/Highlight.java b/backend/src/main/java/reviewme/highlight/domain/Highlight.java index ba2a85ce5..de6714efa 100644 --- a/backend/src/main/java/reviewme/highlight/domain/Highlight.java +++ b/backend/src/main/java/reviewme/highlight/domain/Highlight.java @@ -29,7 +29,7 @@ public class Highlight { @Embedded private HighlightPosition highlightPosition; - public Highlight(long answerId, long lineIndex, long startIndex, long endIndex) { + public Highlight(long answerId, int lineIndex, int startIndex, int endIndex) { this.answerId = answerId; this.highlightPosition = new HighlightPosition(lineIndex, startIndex, endIndex); } diff --git a/backend/src/main/java/reviewme/highlight/domain/HighlightPosition.java b/backend/src/main/java/reviewme/highlight/domain/HighlightPosition.java index cd03bb5b7..25463cd51 100644 --- a/backend/src/main/java/reviewme/highlight/domain/HighlightPosition.java +++ b/backend/src/main/java/reviewme/highlight/domain/HighlightPosition.java @@ -16,15 +16,15 @@ public class HighlightPosition { @Column(name = "line_index", nullable = false) - private long lineIndex; + private int lineIndex; @Column(name = "start_index", nullable = false) - private long startIndex; + private int startIndex; @Column(name = "end_index", nullable = false) - private long endIndex; + private int endIndex; - public HighlightPosition(long lineIndex, long startIndex, long endIndex) { + public HighlightPosition(int lineIndex, int startIndex, int endIndex) { validateNonNegativeIndexNumber(startIndex, endIndex); validateEndIndexOverStartIndex(startIndex, endIndex); this.lineIndex = lineIndex; diff --git a/backend/src/main/java/reviewme/highlight/service/dto/HighlightIndexRangeRequest.java b/backend/src/main/java/reviewme/highlight/service/dto/HighlightIndexRangeRequest.java index fde581308..42b7394e4 100644 --- a/backend/src/main/java/reviewme/highlight/service/dto/HighlightIndexRangeRequest.java +++ b/backend/src/main/java/reviewme/highlight/service/dto/HighlightIndexRangeRequest.java @@ -5,9 +5,9 @@ public record HighlightIndexRangeRequest( @NotNull(message = "시작 인덱스를 입력해주세요.") - Long startIndex, + Integer startIndex, @NotNull(message = "끝 인덱스를 입력해주세요.") - Long endIndex + Integer endIndex ) { } diff --git a/backend/src/main/java/reviewme/highlight/service/dto/HighlightedLineRequest.java b/backend/src/main/java/reviewme/highlight/service/dto/HighlightedLineRequest.java index a8275aea3..9188f3bc9 100644 --- a/backend/src/main/java/reviewme/highlight/service/dto/HighlightedLineRequest.java +++ b/backend/src/main/java/reviewme/highlight/service/dto/HighlightedLineRequest.java @@ -8,7 +8,7 @@ public record HighlightedLineRequest( @NotNull(message = "인덱스를 입력해주세요.") - Long index, + Integer index, @Valid @NotEmpty(message = "하이라이트 범위를 입력해주세요.") List ranges diff --git a/backend/src/test/java/reviewme/highlight/service/HighlightServiceTest.java b/backend/src/test/java/reviewme/highlight/service/HighlightServiceTest.java index d02a9909f..0128b9d3e 100644 --- a/backend/src/test/java/reviewme/highlight/service/HighlightServiceTest.java +++ b/backend/src/test/java/reviewme/highlight/service/HighlightServiceTest.java @@ -66,8 +66,8 @@ class HighlightServiceTest { TextAnswer textAnswer2 = new TextAnswer(questionId, "text answer2"); Review review = reviewRepository.save(new Review(templateId, reviewGroupId, List.of(textAnswer1, textAnswer2))); - HighlightIndexRangeRequest indexRangeRequest = new HighlightIndexRangeRequest(1L, 1L); - HighlightedLineRequest lineRequest = new HighlightedLineRequest(0L, List.of(indexRangeRequest)); + HighlightIndexRangeRequest indexRangeRequest = new HighlightIndexRangeRequest(1, 1); + HighlightedLineRequest lineRequest = new HighlightedLineRequest(0, List.of(indexRangeRequest)); HighlightRequest highlightRequest1 = new HighlightRequest(textAnswer1.getId(), List.of(lineRequest)); HighlightRequest highlightRequest2 = new HighlightRequest(textAnswer2.getId(), List.of(lineRequest)); HighlightsRequest highlightsRequest = new HighlightsRequest( @@ -99,9 +99,9 @@ class HighlightServiceTest { TextAnswer textAnswer2 = new TextAnswer(questionId, "text answer2"); Review review = reviewRepository.save(new Review(templateId, reviewGroupId, List.of(textAnswer1, textAnswer2))); - long startIndex = 2L; - long endIndex = 2L; - long lineIndex = 0; + int startIndex = 2; + int endIndex = 2; + int lineIndex = 0; HighlightIndexRangeRequest indexRangeRequest = new HighlightIndexRangeRequest(startIndex, endIndex); HighlightedLineRequest lineRequest1 = new HighlightedLineRequest(lineIndex, List.of(indexRangeRequest)); HighlightedLineRequest lineRequest2 = new HighlightedLineRequest(lineIndex, List.of(indexRangeRequest)); diff --git a/backend/src/test/java/reviewme/highlight/service/validator/HighlightValidatorTest.java b/backend/src/test/java/reviewme/highlight/service/validator/HighlightValidatorTest.java index 10fddba5f..21f1f8638 100644 --- a/backend/src/test/java/reviewme/highlight/service/validator/HighlightValidatorTest.java +++ b/backend/src/test/java/reviewme/highlight/service/validator/HighlightValidatorTest.java @@ -116,7 +116,7 @@ class HighlightValidatorTest { Review review = reviewRepository.save(new Review(templateId, reviewGroupId, List.of(textAnswer))); long answerLineCount = textAnswer.getContent().lines().count(); - HighlightedLineRequest highlightedLineRequest = new HighlightedLineRequest(answerLineCount, List.of()); + HighlightedLineRequest highlightedLineRequest = new HighlightedLineRequest((int) answerLineCount, List.of()); HighlightRequest highlightRequest = new HighlightRequest(textAnswer.getId(), List.of(highlightedLineRequest)); HighlightsRequest highlightsRequest = new HighlightsRequest(questionId, List.of(highlightRequest)); From b6d03d1481a7cf7176f573ae459c6582fa2d5697 Mon Sep 17 00:00:00 2001 From: Donghoon Lee Date: Fri, 11 Oct 2024 17:38:16 +0900 Subject: [PATCH 16/49] =?UTF-8?q?[BE]=20refactor:=20NativeQuery=20JPQL?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=ED=99=98=20=EB=B0=8F=20`value`=20?= =?UTF-8?q?=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EC=83=9D=EB=9E=B5=20(#8?= =?UTF-8?q?08)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/OptionGroupRepository.java | 8 ++--- .../repository/OptionItemRepository.java | 20 +++++------ .../repository/QuestionRepository.java | 34 +++++++++--------- .../review/repository/AnswerRepository.java | 8 ++--- .../review/repository/ReviewRepository.java | 36 +++++++++---------- .../repository/SectionRepository.java | 12 +++---- 6 files changed, 57 insertions(+), 61 deletions(-) diff --git a/backend/src/main/java/reviewme/question/repository/OptionGroupRepository.java b/backend/src/main/java/reviewme/question/repository/OptionGroupRepository.java index 3935d6a8f..ad2994537 100644 --- a/backend/src/main/java/reviewme/question/repository/OptionGroupRepository.java +++ b/backend/src/main/java/reviewme/question/repository/OptionGroupRepository.java @@ -12,9 +12,9 @@ public interface OptionGroupRepository extends JpaRepository Optional findByQuestionId(long questionId); - @Query(value = """ - SELECT og.* FROM option_group og - WHERE og.question_id IN (:questionIds) - """, nativeQuery = true) + @Query(""" + SELECT og FROM OptionGroup og + WHERE og.questionId IN :questionIds + """) List findAllByQuestionIds(List questionIds); } diff --git a/backend/src/main/java/reviewme/question/repository/OptionItemRepository.java b/backend/src/main/java/reviewme/question/repository/OptionItemRepository.java index 6466aa0bf..e42274c33 100644 --- a/backend/src/main/java/reviewme/question/repository/OptionItemRepository.java +++ b/backend/src/main/java/reviewme/question/repository/OptionItemRepository.java @@ -12,17 +12,17 @@ public interface OptionItemRepository extends JpaRepository { List findAllByOptionGroupId(long optionGroupId); - @Query(value = """ - SELECT o.* FROM option_item o - WHERE o.option_type = :#{#optionType.name()} - """, nativeQuery = true) + @Query(""" + SELECT o FROM OptionItem o + WHERE o.optionType = :optionType + """) List findAllByOptionType(OptionType optionType); - @Query(value = """ - SELECT o.* FROM option_item o - JOIN option_group og - ON o.option_group_id = og.id - WHERE og.question_id IN (:questionIds) - """, nativeQuery = true) + @Query(""" + SELECT o FROM OptionItem o + JOIN OptionGroup og + ON o.optionGroupId = og.id + WHERE og.questionId IN :questionIds + """) List findAllByQuestionIds(List questionIds); } diff --git a/backend/src/main/java/reviewme/question/repository/QuestionRepository.java b/backend/src/main/java/reviewme/question/repository/QuestionRepository.java index 889202bf3..9db137d25 100644 --- a/backend/src/main/java/reviewme/question/repository/QuestionRepository.java +++ b/backend/src/main/java/reviewme/question/repository/QuestionRepository.java @@ -11,27 +11,27 @@ @Repository public interface QuestionRepository extends JpaRepository { - @Query(value = """ - SELECT q.id FROM question q - JOIN section_question sq - ON q.id = sq.question_id - JOIN template_section ts - ON sq.section_id = ts.section_id - WHERE ts.template_id = :templateId - """, nativeQuery = true) + @Query(""" + SELECT q.id FROM Question q + JOIN SectionQuestion sq + ON q.id = sq.questionId + JOIN TemplateSection ts + ON sq.sectionId = ts.sectionId + WHERE ts.templateId = :templateId + """) Set findAllQuestionIdByTemplateId(long templateId); - @Query(value = """ - SELECT q.* FROM question q - JOIN section_question sq - ON q.id = sq.question_id - JOIN template_section ts - ON sq.section_id = ts.section_id - WHERE ts.template_id = :templateId - """, nativeQuery = true) + @Query(""" + SELECT q FROM Question q + JOIN SectionQuestion sq + ON q.id = sq.questionId + JOIN TemplateSection ts + ON sq.sectionId = ts.sectionId + WHERE ts.templateId = :templateId + """) List findAllByTemplatedId(long templateId); - @Query(value = """ + @Query(""" SELECT q FROM Question q JOIN SectionQuestion sq ON q.id = sq.questionId WHERE sq.sectionId = :sectionId diff --git a/backend/src/main/java/reviewme/review/repository/AnswerRepository.java b/backend/src/main/java/reviewme/review/repository/AnswerRepository.java index 821fbb7cc..d296f89fe 100644 --- a/backend/src/main/java/reviewme/review/repository/AnswerRepository.java +++ b/backend/src/main/java/reviewme/review/repository/AnswerRepository.java @@ -11,7 +11,7 @@ @Repository public interface AnswerRepository extends JpaRepository { - @Query(value = """ + @Query(""" SELECT a FROM Answer a JOIN Review r ON a.reviewId = r.id WHERE r.reviewGroupId = :reviewGroupId AND a.questionId IN :questionIds @@ -20,7 +20,7 @@ public interface AnswerRepository extends JpaRepository { """) List findReceivedAnswersByQuestionIds(long reviewGroupId, Collection questionIds, int limit); - @Query(value = """ + @Query(""" SELECT a.id FROM Answer a JOIN Review r ON a.reviewId = r.id @@ -28,7 +28,7 @@ public interface AnswerRepository extends JpaRepository { """) Set findIdsByReviewGroupId(long reviewGroupId); - @Query(value = """ + @Query(""" SELECT a FROM Answer a JOIN Review r ON a.reviewId = r.id @@ -36,7 +36,7 @@ public interface AnswerRepository extends JpaRepository { """) Set findAllByReviewGroupId(long reviewGroupId); - @Query(value = """ + @Query(""" SELECT a.id FROM Answer a WHERE a.questionId = :questionId """) diff --git a/backend/src/main/java/reviewme/review/repository/ReviewRepository.java b/backend/src/main/java/reviewme/review/repository/ReviewRepository.java index bde88eeac..3a0600ad9 100644 --- a/backend/src/main/java/reviewme/review/repository/ReviewRepository.java +++ b/backend/src/main/java/reviewme/review/repository/ReviewRepository.java @@ -9,35 +9,31 @@ public interface ReviewRepository extends JpaRepository { - @Query(value = """ - SELECT r.* FROM new_review r - WHERE r.review_group_id = :reviewGroupId - ORDER BY r.created_at DESC - """, nativeQuery = true) + @Query(""" + SELECT r FROM Review r + WHERE r.reviewGroupId = :reviewGroupId + ORDER BY r.createdAt DESC + """) List findAllByGroupId(long reviewGroupId); - @Query(value = """ - SELECT r.* FROM new_review r - WHERE r.review_group_id = :reviewGroupId + @Query(""" + SELECT r FROM Review r + WHERE r.reviewGroupId = :reviewGroupId AND (:lastReviewId IS NULL OR r.id < :lastReviewId) - ORDER BY r.created_at DESC, r.id DESC + ORDER BY r.createdAt DESC, r.id DESC LIMIT :limit - """, nativeQuery = true) + """) List findByReviewGroupIdWithLimit(long reviewGroupId, Long lastReviewId, int limit); Optional findByIdAndReviewGroupId(long reviewId, long reviewGroupId); - @Query(value = """ - SELECT COUNT(r.id) FROM new_review r - WHERE r.review_group_id = :reviewGroupId + @Query(""" + SELECT COUNT(r.id) > 0 FROM Review r + WHERE r.reviewGroupId = :reviewGroupId AND r.id < :reviewId - AND CAST(r.created_at AS DATE) <= :createdDate - """, nativeQuery = true) - Long existsOlderReviewInGroupInLong(long reviewGroupId, long reviewId, LocalDate createdDate); - - default boolean existsOlderReviewInGroup(long reviewGroupId, long reviewId, LocalDate createdDate) { - return existsOlderReviewInGroupInLong(reviewGroupId, reviewId, createdDate) > 0; - } + AND CAST(r.createdAt AS java.time.LocalDate) <= :createdDate + """) + boolean existsOlderReviewInGroup(long reviewGroupId, long reviewId, LocalDate createdDate); int countByReviewGroupId(long reviewGroupId); } diff --git a/backend/src/main/java/reviewme/template/repository/SectionRepository.java b/backend/src/main/java/reviewme/template/repository/SectionRepository.java index ae0aff9f6..d40fa5a24 100644 --- a/backend/src/main/java/reviewme/template/repository/SectionRepository.java +++ b/backend/src/main/java/reviewme/template/repository/SectionRepository.java @@ -10,13 +10,13 @@ @Repository public interface SectionRepository extends JpaRepository { - @Query(value = """ - SELECT s.* FROM section s - JOIN template_section ts - ON s.id = ts.section_id - WHERE ts.template_id = :templateId + @Query(""" + SELECT s FROM Section s + JOIN TemplateSection ts + ON s.id = ts.sectionId + WHERE ts.templateId = :templateId ORDER BY s.position ASC - """, nativeQuery = true) + """) List
findAllByTemplateId(long templateId); @Query(""" From 6b3a8d3749c32b668428282865ebc03a5cabff67 Mon Sep 17 00:00:00 2001 From: Donghoon Lee Date: Mon, 14 Oct 2024 17:36:55 +0900 Subject: [PATCH 17/49] =?UTF-8?q?[BE]=20fix:=20Dto=20=ED=95=98=EC=9C=84=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=EC=97=90=20`@Valid`=20=EC=B6=94=EA=B0=80=20(?= =?UTF-8?q?#845)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Dto 하위 필드에 `@Valid` 추가 * chore: `@Valid @ResponseBody` 순서 통일 --- .../review/service/dto/request/ReviewRegisterRequest.java | 3 ++- .../reviewme/reviewgroup/controller/ReviewGroupController.java | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/src/main/java/reviewme/review/service/dto/request/ReviewRegisterRequest.java b/backend/src/main/java/reviewme/review/service/dto/request/ReviewRegisterRequest.java index 1b7a6f896..791bbb519 100644 --- a/backend/src/main/java/reviewme/review/service/dto/request/ReviewRegisterRequest.java +++ b/backend/src/main/java/reviewme/review/service/dto/request/ReviewRegisterRequest.java @@ -1,5 +1,6 @@ package reviewme.review.service.dto.request; +import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotEmpty; import java.util.List; @@ -9,7 +10,7 @@ public record ReviewRegisterRequest( @NotBlank(message = "리뷰 요청 코드를 입력해주세요.") String reviewRequestCode, - @NotEmpty(message = "답변 내용을 입력해주세요.") + @Valid @NotEmpty(message = "답변 내용을 입력해주세요.") List answers ) { } diff --git a/backend/src/main/java/reviewme/reviewgroup/controller/ReviewGroupController.java b/backend/src/main/java/reviewme/reviewgroup/controller/ReviewGroupController.java index 3d1968bc8..b6c7a973c 100644 --- a/backend/src/main/java/reviewme/reviewgroup/controller/ReviewGroupController.java +++ b/backend/src/main/java/reviewme/reviewgroup/controller/ReviewGroupController.java @@ -40,7 +40,7 @@ public ResponseEntity createReviewGroup( @PostMapping("/v2/groups/check") public ResponseEntity checkGroupAccessCode( - @RequestBody @Valid CheckValidAccessRequest request, + @Valid @RequestBody CheckValidAccessRequest request, HttpServletRequest httpRequest ) { reviewGroupService.checkGroupAccessCode(request); From 371f4882d9f33ecdfb574b4dee5156e9b1c32c97 Mon Sep 17 00:00:00 2001 From: Donghoon Lee Date: Mon, 14 Oct 2024 19:52:08 +0900 Subject: [PATCH 18/49] =?UTF-8?q?[BE]=20feat:=20UTF-8=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20(#844)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 필터 추가 * chore: `Filter` 대신 `yml` 설정 --- backend/src/main/resources/application.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 4181d5513..45df6e2cb 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -29,6 +29,9 @@ server: same-site: strict http-only: true secure: true + encoding: + charset: UTF-8 + force: true cors: allowed-origins: From d93a1a704fb00a0a8c174e7ae1b3a8a5787c92c8 Mon Sep 17 00:00:00 2001 From: Kimprodp Date: Mon, 14 Oct 2024 21:23:38 +0900 Subject: [PATCH 19/49] =?UTF-8?q?[BE]=20refactor:=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EA=B7=B8=EB=A3=B9=EC=97=90=20=ED=85=9C=ED=94=8C=EB=A6=BF=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=EB=A5=BC=20=ED=95=98=EB=93=9C=EC=BD=94?= =?UTF-8?q?=EB=94=A9=20=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20(#812)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 리뷰 그룹 생성 시, 템플릿 id를 설정하도록 변경 * test: 변경 사항 테스트 반영 --- .../reviewme/reviewgroup/domain/ReviewGroup.java | 9 +++------ .../reviewgroup/service/ReviewGroupService.java | 11 ++++++++++- .../exception/TemplateNotFoundException.java | 13 +++++++++++++ .../java/reviewme/fixture/ReviewGroupFixture.java | 2 +- .../java/reviewme/reviewgroup/ReviewGroupTest.java | 12 ++++++------ .../service/ReviewGroupLookupServiceTest.java | 3 ++- .../reviewgroup/service/ReviewGroupServiceTest.java | 7 +++++++ 7 files changed, 42 insertions(+), 15 deletions(-) create mode 100644 backend/src/main/java/reviewme/template/service/exception/TemplateNotFoundException.java diff --git a/backend/src/main/java/reviewme/reviewgroup/domain/ReviewGroup.java b/backend/src/main/java/reviewme/reviewgroup/domain/ReviewGroup.java index ee5205424..dedd25b60 100644 --- a/backend/src/main/java/reviewme/reviewgroup/domain/ReviewGroup.java +++ b/backend/src/main/java/reviewme/reviewgroup/domain/ReviewGroup.java @@ -41,13 +41,10 @@ public class ReviewGroup { private GroupAccessCode groupAccessCode; @Column(name = "template_id", nullable = false) - private long templateId = 1L; + private long templateId; - public ReviewGroup(String reviewee, String projectName, String reviewRequestCode, String groupAccessCode) { - this(reviewee, projectName, reviewRequestCode, groupAccessCode, 1L); - } - - public ReviewGroup(String reviewee, String projectName, String reviewRequestCode, String groupAccessCode, long templateId) { + public ReviewGroup(String reviewee, String projectName, String reviewRequestCode, String groupAccessCode, + long templateId) { validateRevieweeLength(reviewee); validateProjectNameLength(projectName); this.reviewee = reviewee; diff --git a/backend/src/main/java/reviewme/reviewgroup/service/ReviewGroupService.java b/backend/src/main/java/reviewme/reviewgroup/service/ReviewGroupService.java index c9f314b66..e8e4ac0ba 100644 --- a/backend/src/main/java/reviewme/reviewgroup/service/ReviewGroupService.java +++ b/backend/src/main/java/reviewme/reviewgroup/service/ReviewGroupService.java @@ -10,15 +10,20 @@ import reviewme.reviewgroup.service.dto.CheckValidAccessRequest; import reviewme.reviewgroup.service.dto.ReviewGroupCreationRequest; import reviewme.reviewgroup.service.dto.ReviewGroupCreationResponse; +import reviewme.template.domain.Template; +import reviewme.template.repository.TemplateRepository; +import reviewme.template.service.exception.TemplateNotFoundException; @Service @RequiredArgsConstructor public class ReviewGroupService { private static final int REVIEW_REQUEST_CODE_LENGTH = 8; + private static final long DEFAULT_TEMPLATE_ID = 1L; private final ReviewGroupRepository reviewGroupRepository; private final RandomCodeGenerator randomCodeGenerator; + private final TemplateRepository templateRepository; @Transactional public ReviewGroupCreationResponse createReviewGroup(ReviewGroupCreationRequest request) { @@ -27,9 +32,13 @@ public ReviewGroupCreationResponse createReviewGroup(ReviewGroupCreationRequest reviewRequestCode = randomCodeGenerator.generate(REVIEW_REQUEST_CODE_LENGTH); } while (reviewGroupRepository.existsByReviewRequestCode(reviewRequestCode)); + Template template = templateRepository.findById(DEFAULT_TEMPLATE_ID) + .orElseThrow(() -> new TemplateNotFoundException(DEFAULT_TEMPLATE_ID)); + ReviewGroup reviewGroup = reviewGroupRepository.save( new ReviewGroup( - request.revieweeName(), request.projectName(), reviewRequestCode, request.groupAccessCode() + request.revieweeName(), request.projectName(), reviewRequestCode, request.groupAccessCode(), + template.getId() ) ); return new ReviewGroupCreationResponse(reviewGroup.getReviewRequestCode()); diff --git a/backend/src/main/java/reviewme/template/service/exception/TemplateNotFoundException.java b/backend/src/main/java/reviewme/template/service/exception/TemplateNotFoundException.java new file mode 100644 index 000000000..84a7e58d0 --- /dev/null +++ b/backend/src/main/java/reviewme/template/service/exception/TemplateNotFoundException.java @@ -0,0 +1,13 @@ +package reviewme.template.service.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.DataInconsistencyException; + +@Slf4j +public class TemplateNotFoundException extends DataInconsistencyException { + + public TemplateNotFoundException(long templateId) { + super("서버 내부에서 문제가 발생했어요. 서버에 문의해주세요."); + log.error("Template not found - templateId: {}", templateId, this); + } +} diff --git a/backend/src/test/java/reviewme/fixture/ReviewGroupFixture.java b/backend/src/test/java/reviewme/fixture/ReviewGroupFixture.java index 5ae84fe0d..caf03a6ef 100644 --- a/backend/src/test/java/reviewme/fixture/ReviewGroupFixture.java +++ b/backend/src/test/java/reviewme/fixture/ReviewGroupFixture.java @@ -9,6 +9,6 @@ public class ReviewGroupFixture { } public static ReviewGroup 리뷰_그룹(String reviewRequestCode, String groupAccessCode) { - return new ReviewGroup("revieweeName", "projectName", reviewRequestCode, groupAccessCode); + return new ReviewGroup("revieweeName", "projectName", reviewRequestCode, groupAccessCode, 1L); } } diff --git a/backend/src/test/java/reviewme/reviewgroup/ReviewGroupTest.java b/backend/src/test/java/reviewme/reviewgroup/ReviewGroupTest.java index d6ac6055a..d67b5f849 100644 --- a/backend/src/test/java/reviewme/reviewgroup/ReviewGroupTest.java +++ b/backend/src/test/java/reviewme/reviewgroup/ReviewGroupTest.java @@ -20,9 +20,9 @@ class ReviewGroupTest { // when, then assertAll( - () -> assertThatCode(() -> new ReviewGroup(minLengthName, "project", "reviewCode", "groupCode")) + () -> assertThatCode(() -> new ReviewGroup(minLengthName, "project", "reviewCode", "groupCode", 1L)) .doesNotThrowAnyException(), - () -> assertThatCode(() -> new ReviewGroup(maxLengthName, "project", "reviewCode", "groupCode")) + () -> assertThatCode(() -> new ReviewGroup(maxLengthName, "project", "reviewCode", "groupCode", 1L)) .doesNotThrowAnyException() ); } @@ -37,9 +37,9 @@ class ReviewGroupTest { // when, then assertAll( - () -> assertThatCode(() -> new ReviewGroup(insufficientName, "project", "reviewCode", "groupCode")) + () -> assertThatCode(() -> new ReviewGroup(insufficientName, "project", "reviewCode", "groupCode", 1L)) .isInstanceOf(BadRequestException.class), - () -> assertThatThrownBy(() -> new ReviewGroup(exceedName, "project", "reviewCode", "groupCode")) + () -> assertThatThrownBy(() -> new ReviewGroup(exceedName, "project", "reviewCode", "groupCode", 1L)) .isInstanceOf(BadRequestException.class) ); } @@ -54,9 +54,9 @@ class ReviewGroupTest { // when, then assertAll( - () -> assertThatThrownBy(() -> new ReviewGroup("reviwee", insufficientName, "reviewCode", "groupCode")) + () -> assertThatThrownBy(() -> new ReviewGroup("reviwee", insufficientName, "reviewCode", "groupCode", 1L)) .isInstanceOf(BadRequestException.class), - () -> assertThatThrownBy(() -> new ReviewGroup("reviwee", exceedName, "reviewCode", "groupCode")) + () -> assertThatThrownBy(() -> new ReviewGroup("reviwee", exceedName, "reviewCode", "groupCode", 1L)) .isInstanceOf(BadRequestException.class) ); } diff --git a/backend/src/test/java/reviewme/reviewgroup/service/ReviewGroupLookupServiceTest.java b/backend/src/test/java/reviewme/reviewgroup/service/ReviewGroupLookupServiceTest.java index 1e905dc95..a7719e52f 100644 --- a/backend/src/test/java/reviewme/reviewgroup/service/ReviewGroupLookupServiceTest.java +++ b/backend/src/test/java/reviewme/reviewgroup/service/ReviewGroupLookupServiceTest.java @@ -28,7 +28,8 @@ class ReviewGroupLookupServiceTest { "ted", "review-me", "reviewRequestCode", - "groupAccessCode" + "groupAccessCode", + 1L )); // when diff --git a/backend/src/test/java/reviewme/reviewgroup/service/ReviewGroupServiceTest.java b/backend/src/test/java/reviewme/reviewgroup/service/ReviewGroupServiceTest.java index d1c5a54e5..2694da49c 100644 --- a/backend/src/test/java/reviewme/reviewgroup/service/ReviewGroupServiceTest.java +++ b/backend/src/test/java/reviewme/reviewgroup/service/ReviewGroupServiceTest.java @@ -9,7 +9,9 @@ import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.times; import static reviewme.fixture.ReviewGroupFixture.리뷰_그룹; +import static reviewme.fixture.TemplateFixture.템플릿; +import java.util.List; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.junit.jupiter.MockitoExtension; @@ -21,6 +23,7 @@ import reviewme.reviewgroup.service.dto.ReviewGroupCreationRequest; import reviewme.reviewgroup.service.dto.ReviewGroupCreationResponse; import reviewme.support.ServiceTest; +import reviewme.template.repository.TemplateRepository; @ServiceTest @ExtendWith(MockitoExtension.class) @@ -35,9 +38,13 @@ class ReviewGroupServiceTest { @Autowired private ReviewGroupRepository reviewGroupRepository; + @Autowired + private TemplateRepository templateRepository; + @Test void 코드가_중복되는_경우_다시_생성한다() { // given + templateRepository.save(템플릿(List.of())); reviewGroupRepository.save(리뷰_그룹("0000", "1111")); given(randomCodeGenerator.generate(anyInt())) .willReturn("0000") // ReviewRequestCode From 8c3c5aecf11499868adcf06bfc69d44a4680ceae Mon Sep 17 00:00:00 2001 From: Donghoon Lee Date: Tue, 15 Oct 2024 14:22:25 +0900 Subject: [PATCH 20/49] =?UTF-8?q?[BE]=20refactor:=20=EC=84=B8=EC=85=98?= =?UTF-8?q?=EC=9D=84=20=EA=B0=80=EC=A0=B8=EC=98=A4=EB=8A=94=20=EC=97=AD?= =?UTF-8?q?=ED=95=A0=EC=9D=84=20`ReviewGroupSessionResolver`=EC=97=90=20?= =?UTF-8?q?=EC=9C=84=EC=9E=84=20(#843)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 사용하지 않는 클래스 제거 * feat: 세션에서 리뷰 그룹 resolve * refactor: `Resolver` `@MockBean` 등록 내부에 Repository가 존재하여 자체를 Mocking합니다. 이에 대한 테스트는 따로 진행했어요. * refactor: `CorsConfig` Disable 처리 두 테스트 때문에 컨텍스트가 두 개나 뜹니다.. * feat: `Resolver`를 사용해 리뷰 그룹을 가져오도록 구현 * chore: 사용하지 않는 `ReviewGroupRepository` 필드 제거 * refactor: 서비스 메서드 추가 및 `ServiceTest` 제거 * fix: `ReviewGroupService` mockbean 처리 * chore: -요 체로 변경 --- .../main/java/reviewme/config/WebConfig.java | 9 ++- .../java/reviewme/global/HeaderProperty.java | 18 ----- .../HeaderPropertyArgumentResolver.java | 32 --------- .../MissingHeaderPropertyException.java | 12 ---- .../controller/HighlightController.java | 7 +- .../highlight/service/HighlightService.java | 11 +-- .../review/controller/ReviewController.java | 26 +++---- .../service/ReviewDetailLookupService.java | 9 +-- .../service/ReviewGatheredLookupService.java | 11 +-- .../service/ReviewListLookupService.java | 8 +-- .../review/service/ReviewSummaryService.java | 8 +-- .../controller/ReviewGroupSession.java | 11 +++ .../ReviewGroupSessionNotFoundException.java | 12 ++++ .../ReviewGroupSessionResolver.java | 42 +++++++++++ .../reviewgroup/domain/ReviewGroup.java | 2 + .../service/ReviewGroupService.java | 6 ++ .../controller/SectionController.java | 7 +- .../template/service/SectionService.java | 11 +-- .../src/test/java/reviewme/api/ApiTest.java | 4 ++ .../test/java/reviewme/api/ReviewApiTest.java | 8 +-- .../java/reviewme/api/TemplateApiTest.java | 3 +- .../java/reviewme/config/CorsConfigTest.java | 5 ++ .../config/ExternalCorsConfigTest.java | 2 + .../reviewme/config/LocalCorsConfigTest.java | 2 + .../HeaderPropertyArgumentResolverTest.java | 56 --------------- .../service/HighlightServiceTest.java | 24 +++---- .../ReviewDetailLookupServiceTest.java | 37 ++-------- .../ReviewGatheredLookupServiceTest.java | 29 +++++--- .../service/ReviewListLookupServiceTest.java | 13 +--- .../service/ReviewSummaryServiceTest.java | 16 +---- .../ReviewGroupSessionResolverTest.java | 71 +++++++++++++++++++ .../service/ReviewGroupServiceTest.java | 25 +++++++ .../template/service/SectionServiceTest.java | 15 +--- 33 files changed, 269 insertions(+), 283 deletions(-) delete mode 100644 backend/src/main/java/reviewme/global/HeaderProperty.java delete mode 100644 backend/src/main/java/reviewme/global/HeaderPropertyArgumentResolver.java delete mode 100644 backend/src/main/java/reviewme/global/exception/MissingHeaderPropertyException.java create mode 100644 backend/src/main/java/reviewme/reviewgroup/controller/ReviewGroupSession.java create mode 100644 backend/src/main/java/reviewme/reviewgroup/controller/ReviewGroupSessionNotFoundException.java create mode 100644 backend/src/main/java/reviewme/reviewgroup/controller/ReviewGroupSessionResolver.java delete mode 100644 backend/src/test/java/reviewme/global/HeaderPropertyArgumentResolverTest.java create mode 100644 backend/src/test/java/reviewme/reviewgroup/controller/ReviewGroupSessionResolverTest.java diff --git a/backend/src/main/java/reviewme/config/WebConfig.java b/backend/src/main/java/reviewme/config/WebConfig.java index 423c8f0e5..d855040f0 100644 --- a/backend/src/main/java/reviewme/config/WebConfig.java +++ b/backend/src/main/java/reviewme/config/WebConfig.java @@ -1,16 +1,21 @@ package reviewme.config; import java.util.List; +import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Configuration; import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; -import reviewme.global.HeaderPropertyArgumentResolver; +import reviewme.reviewgroup.controller.ReviewGroupSessionResolver; +import reviewme.reviewgroup.service.ReviewGroupService; @Configuration +@RequiredArgsConstructor public class WebConfig implements WebMvcConfigurer { + private final ReviewGroupService reviewGroupService; + @Override public void addArgumentResolvers(List resolvers) { - resolvers.add(new HeaderPropertyArgumentResolver()); + resolvers.add(new ReviewGroupSessionResolver(reviewGroupService)); } } diff --git a/backend/src/main/java/reviewme/global/HeaderProperty.java b/backend/src/main/java/reviewme/global/HeaderProperty.java deleted file mode 100644 index 86462c596..000000000 --- a/backend/src/main/java/reviewme/global/HeaderProperty.java +++ /dev/null @@ -1,18 +0,0 @@ -package reviewme.global; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; -import org.springframework.core.annotation.AliasFor; - -@Target(ElementType.PARAMETER) -@Retention(RetentionPolicy.RUNTIME) -public @interface HeaderProperty { - - @AliasFor("headerName") - String value() default ""; - - @AliasFor("value") - String headerName() default ""; -} diff --git a/backend/src/main/java/reviewme/global/HeaderPropertyArgumentResolver.java b/backend/src/main/java/reviewme/global/HeaderPropertyArgumentResolver.java deleted file mode 100644 index 5c825e3de..000000000 --- a/backend/src/main/java/reviewme/global/HeaderPropertyArgumentResolver.java +++ /dev/null @@ -1,32 +0,0 @@ -package reviewme.global; - -import jakarta.servlet.http.HttpServletRequest; -import org.springframework.core.MethodParameter; -import org.springframework.web.bind.support.WebDataBinderFactory; -import org.springframework.web.context.request.NativeWebRequest; -import org.springframework.web.method.support.HandlerMethodArgumentResolver; -import org.springframework.web.method.support.ModelAndViewContainer; -import reviewme.global.exception.MissingHeaderPropertyException; - -public class HeaderPropertyArgumentResolver implements HandlerMethodArgumentResolver { - - @Override - public boolean supportsParameter(MethodParameter parameter) { - return parameter.hasParameterAnnotation(HeaderProperty.class); - } - - @Override - public String resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, - NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { - HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest(); - - HeaderProperty parameterAnnotation = parameter.getParameterAnnotation(HeaderProperty.class); - String headerName = parameterAnnotation.headerName(); - String headerProperty = request.getHeader(headerName); - - if (headerProperty == null) { - throw new MissingHeaderPropertyException(headerName); - } - return headerProperty; - } -} diff --git a/backend/src/main/java/reviewme/global/exception/MissingHeaderPropertyException.java b/backend/src/main/java/reviewme/global/exception/MissingHeaderPropertyException.java deleted file mode 100644 index 8fc4dd76f..000000000 --- a/backend/src/main/java/reviewme/global/exception/MissingHeaderPropertyException.java +++ /dev/null @@ -1,12 +0,0 @@ -package reviewme.global.exception; - -import lombok.extern.slf4j.Slf4j; - -@Slf4j -public class MissingHeaderPropertyException extends BadRequestException { - - public MissingHeaderPropertyException(String headerName) { - super("요청에 %s이(가) 존재하지 않아요.".formatted(headerName)); - log.info("Missing header property: {}", headerName); - } -} diff --git a/backend/src/main/java/reviewme/highlight/controller/HighlightController.java b/backend/src/main/java/reviewme/highlight/controller/HighlightController.java index 286d82a85..a76c6c298 100644 --- a/backend/src/main/java/reviewme/highlight/controller/HighlightController.java +++ b/backend/src/main/java/reviewme/highlight/controller/HighlightController.java @@ -6,9 +6,10 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.bind.annotation.SessionAttribute; import reviewme.highlight.service.HighlightService; import reviewme.highlight.service.dto.HighlightsRequest; +import reviewme.reviewgroup.controller.ReviewGroupSession; +import reviewme.reviewgroup.domain.ReviewGroup; @RestController @RequiredArgsConstructor @@ -19,9 +20,9 @@ public class HighlightController { @PostMapping("/v2/highlight") public ResponseEntity highlight( @Valid @RequestBody HighlightsRequest request, - @SessionAttribute("reviewRequestCode") String reviewRequestCode + @ReviewGroupSession ReviewGroup reviewGroup ) { - highlightService.highlight(request, reviewRequestCode); + highlightService.highlight(request, reviewGroup); return ResponseEntity.ok().build(); } } diff --git a/backend/src/main/java/reviewme/highlight/service/HighlightService.java b/backend/src/main/java/reviewme/highlight/service/HighlightService.java index d123cff8a..8f05d156b 100644 --- a/backend/src/main/java/reviewme/highlight/service/HighlightService.java +++ b/backend/src/main/java/reviewme/highlight/service/HighlightService.java @@ -15,25 +15,20 @@ import reviewme.highlight.service.validator.HighlightValidator; import reviewme.review.domain.Answer; import reviewme.review.repository.AnswerRepository; -import reviewme.review.service.exception.ReviewGroupNotFoundByReviewRequestCodeException; -import reviewme.reviewgroup.repository.ReviewGroupRepository; +import reviewme.reviewgroup.domain.ReviewGroup; @Service @RequiredArgsConstructor public class HighlightService { private final HighlightRepository highlightRepository; - private final ReviewGroupRepository reviewGroupRepository; private final AnswerRepository answerRepository; private final HighlightValidator highlightValidator; @Transactional - public void highlight(HighlightsRequest request, String reviewRequestCode) { - long reviewGroupId = reviewGroupRepository.findByReviewRequestCode(reviewRequestCode) - .orElseThrow(() -> new ReviewGroupNotFoundByReviewRequestCodeException(reviewRequestCode)) - .getId(); - + public void highlight(HighlightsRequest request, ReviewGroup reviewGroup) { + long reviewGroupId = reviewGroup.getId(); highlightValidator.validate(request, reviewGroupId); deleteOldHighlight(request.questionId(), reviewGroupId); saveNewHighlight(request); diff --git a/backend/src/main/java/reviewme/review/controller/ReviewController.java b/backend/src/main/java/reviewme/review/controller/ReviewController.java index 01e091756..1b31af214 100644 --- a/backend/src/main/java/reviewme/review/controller/ReviewController.java +++ b/backend/src/main/java/reviewme/review/controller/ReviewController.java @@ -10,17 +10,18 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.bind.annotation.SessionAttribute; -import reviewme.review.service.ReviewGatheredLookupService; import reviewme.review.service.ReviewDetailLookupService; +import reviewme.review.service.ReviewGatheredLookupService; import reviewme.review.service.ReviewListLookupService; import reviewme.review.service.ReviewRegisterService; import reviewme.review.service.ReviewSummaryService; import reviewme.review.service.dto.request.ReviewRegisterRequest; import reviewme.review.service.dto.response.detail.ReviewDetailResponse; import reviewme.review.service.dto.response.gathered.ReviewsGatheredBySectionResponse; -import reviewme.review.service.dto.response.list.ReceivedReviewsSummaryResponse; import reviewme.review.service.dto.response.list.ReceivedReviewsResponse; +import reviewme.review.service.dto.response.list.ReceivedReviewsSummaryResponse; +import reviewme.reviewgroup.controller.ReviewGroupSession; +import reviewme.reviewgroup.domain.ReviewGroup; @RestController @RequiredArgsConstructor @@ -42,37 +43,36 @@ public ResponseEntity createReview(@Valid @RequestBody ReviewRegisterReque public ResponseEntity findReceivedReviews( @RequestParam(required = false) Long lastReviewId, @RequestParam(required = false) Integer size, - @SessionAttribute("reviewRequestCode") String reviewRequestCode + @ReviewGroupSession ReviewGroup reviewGroup ) { - ReceivedReviewsResponse response = reviewListLookupService.getReceivedReviews( - lastReviewId, size, reviewRequestCode); + ReceivedReviewsResponse response = reviewListLookupService.getReceivedReviews(lastReviewId, size, reviewGroup); return ResponseEntity.ok(response); } @GetMapping("/v2/reviews/{id}") public ResponseEntity findReceivedReviewDetail( @PathVariable long id, - @SessionAttribute("reviewRequestCode") String reviewRequestCode + @ReviewGroupSession ReviewGroup reviewGroup ) { - ReviewDetailResponse response = reviewDetailLookupService.getReviewDetail(id, reviewRequestCode); + ReviewDetailResponse response = reviewDetailLookupService.getReviewDetail(id, reviewGroup); return ResponseEntity.ok(response); } @GetMapping("/v2/reviews/summary") public ResponseEntity findReceivedReviewOverview( - @SessionAttribute("reviewRequestCode") String reviewRequestCode + @ReviewGroupSession ReviewGroup reviewGroup ) { - ReceivedReviewsSummaryResponse response = reviewSummaryService.getReviewSummary(reviewRequestCode); + ReceivedReviewsSummaryResponse response = reviewSummaryService.getReviewSummary(reviewGroup); return ResponseEntity.ok(response); } @GetMapping("/v2/reviews/gather") public ResponseEntity getReceivedReviewsBySectionId( @RequestParam("sectionId") long sectionId, - @SessionAttribute("reviewRequestCode") String reviewRequestCode + @ReviewGroupSession ReviewGroup reviewGroup ) { - ReviewsGatheredBySectionResponse response = reviewGatheredLookupService.getReceivedReviewsBySectionId( - reviewRequestCode, sectionId); + ReviewsGatheredBySectionResponse response = + reviewGatheredLookupService.getReceivedReviewsBySectionId(reviewGroup, sectionId); return ResponseEntity.ok(response); } } diff --git a/backend/src/main/java/reviewme/review/service/ReviewDetailLookupService.java b/backend/src/main/java/reviewme/review/service/ReviewDetailLookupService.java index 7356e7bd8..72f1e7daf 100644 --- a/backend/src/main/java/reviewme/review/service/ReviewDetailLookupService.java +++ b/backend/src/main/java/reviewme/review/service/ReviewDetailLookupService.java @@ -6,11 +6,9 @@ import reviewme.review.domain.Review; import reviewme.review.repository.ReviewRepository; import reviewme.review.service.dto.response.detail.ReviewDetailResponse; -import reviewme.review.service.exception.ReviewGroupNotFoundByReviewRequestCodeException; import reviewme.review.service.exception.ReviewNotFoundByIdAndGroupException; import reviewme.review.service.mapper.ReviewDetailMapper; import reviewme.reviewgroup.domain.ReviewGroup; -import reviewme.reviewgroup.repository.ReviewGroupRepository; @Service @Transactional(readOnly = true) @@ -18,15 +16,10 @@ public class ReviewDetailLookupService { private final ReviewRepository reviewRepository; - private final ReviewGroupRepository reviewGroupRepository; - private final ReviewDetailMapper reviewDetailMapper; @Transactional(readOnly = true) - public ReviewDetailResponse getReviewDetail(long reviewId, String reviewRequestCode) { - ReviewGroup reviewGroup = reviewGroupRepository.findByReviewRequestCode(reviewRequestCode) - .orElseThrow(() -> new ReviewGroupNotFoundByReviewRequestCodeException(reviewRequestCode)); - + public ReviewDetailResponse getReviewDetail(long reviewId, ReviewGroup reviewGroup) { Review review = reviewRepository.findByIdAndReviewGroupId(reviewId, reviewGroup.getId()) .orElseThrow(() -> new ReviewNotFoundByIdAndGroupException(reviewId, reviewGroup.getId())); diff --git a/backend/src/main/java/reviewme/review/service/ReviewGatheredLookupService.java b/backend/src/main/java/reviewme/review/service/ReviewGatheredLookupService.java index 86bb7d728..e8de2aee8 100644 --- a/backend/src/main/java/reviewme/review/service/ReviewGatheredLookupService.java +++ b/backend/src/main/java/reviewme/review/service/ReviewGatheredLookupService.java @@ -12,11 +12,9 @@ import reviewme.review.domain.Answer; import reviewme.review.repository.AnswerRepository; import reviewme.review.service.dto.response.gathered.ReviewsGatheredBySectionResponse; -import reviewme.review.service.exception.ReviewGroupNotFoundByReviewRequestCodeException; import reviewme.review.service.exception.SectionNotFoundInTemplateException; import reviewme.review.service.mapper.ReviewGatherMapper; import reviewme.reviewgroup.domain.ReviewGroup; -import reviewme.reviewgroup.repository.ReviewGroupRepository; import reviewme.template.domain.Section; import reviewme.template.repository.SectionRepository; @@ -28,25 +26,18 @@ public class ReviewGatheredLookupService { private final QuestionRepository questionRepository; private final AnswerRepository answerRepository; - private final ReviewGroupRepository reviewGroupRepository; private final SectionRepository sectionRepository; private final ReviewGatherMapper reviewGatherMapper; @Transactional(readOnly = true) - public ReviewsGatheredBySectionResponse getReceivedReviewsBySectionId(String reviewRequestCode, long sectionId) { - ReviewGroup reviewGroup = getReviewGroupOrThrow(reviewRequestCode); + public ReviewsGatheredBySectionResponse getReceivedReviewsBySectionId(ReviewGroup reviewGroup, long sectionId) { Section section = getSectionOrThrow(sectionId, reviewGroup); Map> questionAnswers = getQuestionAnswers(section, reviewGroup); return reviewGatherMapper.mapToReviewsGatheredBySection(questionAnswers); } - private ReviewGroup getReviewGroupOrThrow(String reviewRequestCode) { - return reviewGroupRepository.findByReviewRequestCode(reviewRequestCode) - .orElseThrow(() -> new ReviewGroupNotFoundByReviewRequestCodeException(reviewRequestCode)); - } - private Section getSectionOrThrow(long sectionId, ReviewGroup reviewGroup) { return sectionRepository.findByIdAndTemplateId(sectionId, reviewGroup.getTemplateId()) .orElseThrow(() -> new SectionNotFoundInTemplateException(sectionId, reviewGroup.getTemplateId())); diff --git a/backend/src/main/java/reviewme/review/service/ReviewListLookupService.java b/backend/src/main/java/reviewme/review/service/ReviewListLookupService.java index 39a4fdfb1..d576e9eeb 100644 --- a/backend/src/main/java/reviewme/review/service/ReviewListLookupService.java +++ b/backend/src/main/java/reviewme/review/service/ReviewListLookupService.java @@ -7,24 +7,18 @@ import reviewme.review.repository.ReviewRepository; import reviewme.review.service.dto.response.list.ReceivedReviewsResponse; import reviewme.review.service.dto.response.list.ReviewListElementResponse; -import reviewme.review.service.exception.ReviewGroupNotFoundByReviewRequestCodeException; import reviewme.review.service.mapper.ReviewListMapper; import reviewme.reviewgroup.domain.ReviewGroup; -import reviewme.reviewgroup.repository.ReviewGroupRepository; @Service @RequiredArgsConstructor public class ReviewListLookupService { - private final ReviewGroupRepository reviewGroupRepository; private final ReviewRepository reviewRepository; private final ReviewListMapper reviewListMapper; @Transactional(readOnly = true) - public ReceivedReviewsResponse getReceivedReviews(Long lastReviewId, Integer size, String reviewRequestCode) { - ReviewGroup reviewGroup = reviewGroupRepository.findByReviewRequestCode(reviewRequestCode) - .orElseThrow(() -> new ReviewGroupNotFoundByReviewRequestCodeException(reviewRequestCode)); - + public ReceivedReviewsResponse getReceivedReviews(Long lastReviewId, Integer size, ReviewGroup reviewGroup) { PageSize pageSize = new PageSize(size); List reviewListResponse = reviewListMapper.mapToReviewList(reviewGroup, lastReviewId, pageSize.getSize()); diff --git a/backend/src/main/java/reviewme/review/service/ReviewSummaryService.java b/backend/src/main/java/reviewme/review/service/ReviewSummaryService.java index 50cc80354..cbd6891a7 100644 --- a/backend/src/main/java/reviewme/review/service/ReviewSummaryService.java +++ b/backend/src/main/java/reviewme/review/service/ReviewSummaryService.java @@ -5,22 +5,16 @@ import org.springframework.transaction.annotation.Transactional; import reviewme.review.repository.ReviewRepository; import reviewme.review.service.dto.response.list.ReceivedReviewsSummaryResponse; -import reviewme.review.service.exception.ReviewGroupNotFoundByReviewRequestCodeException; import reviewme.reviewgroup.domain.ReviewGroup; -import reviewme.reviewgroup.repository.ReviewGroupRepository; @Service @RequiredArgsConstructor public class ReviewSummaryService { - private final ReviewGroupRepository reviewGroupRepository; private final ReviewRepository reviewRepository; @Transactional(readOnly = true) - public ReceivedReviewsSummaryResponse getReviewSummary(String reviewRequestCode) { - ReviewGroup reviewGroup = reviewGroupRepository.findByReviewRequestCode(reviewRequestCode) - .orElseThrow(() -> new ReviewGroupNotFoundByReviewRequestCodeException(reviewRequestCode)); - + public ReceivedReviewsSummaryResponse getReviewSummary(ReviewGroup reviewGroup) { int totalReviewCount = reviewRepository.countByReviewGroupId(reviewGroup.getId()); return new ReceivedReviewsSummaryResponse( diff --git a/backend/src/main/java/reviewme/reviewgroup/controller/ReviewGroupSession.java b/backend/src/main/java/reviewme/reviewgroup/controller/ReviewGroupSession.java new file mode 100644 index 000000000..1024de058 --- /dev/null +++ b/backend/src/main/java/reviewme/reviewgroup/controller/ReviewGroupSession.java @@ -0,0 +1,11 @@ +package reviewme.reviewgroup.controller; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface ReviewGroupSession { +} diff --git a/backend/src/main/java/reviewme/reviewgroup/controller/ReviewGroupSessionNotFoundException.java b/backend/src/main/java/reviewme/reviewgroup/controller/ReviewGroupSessionNotFoundException.java new file mode 100644 index 000000000..f867c4507 --- /dev/null +++ b/backend/src/main/java/reviewme/reviewgroup/controller/ReviewGroupSessionNotFoundException.java @@ -0,0 +1,12 @@ +package reviewme.reviewgroup.controller; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.BadRequestException; + +@Slf4j +public class ReviewGroupSessionNotFoundException extends BadRequestException { + + public ReviewGroupSessionNotFoundException() { + super("리뷰 그룹 세션이 존재하지 않아요."); + } +} diff --git a/backend/src/main/java/reviewme/reviewgroup/controller/ReviewGroupSessionResolver.java b/backend/src/main/java/reviewme/reviewgroup/controller/ReviewGroupSessionResolver.java new file mode 100644 index 000000000..2cc559407 --- /dev/null +++ b/backend/src/main/java/reviewme/reviewgroup/controller/ReviewGroupSessionResolver.java @@ -0,0 +1,42 @@ +package reviewme.reviewgroup.controller; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; +import lombok.RequiredArgsConstructor; +import org.springframework.core.MethodParameter; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; +import reviewme.reviewgroup.domain.ReviewGroup; +import reviewme.reviewgroup.service.ReviewGroupService; + +@RequiredArgsConstructor +public class ReviewGroupSessionResolver implements HandlerMethodArgumentResolver { + + private static final String SESSION_KEY = "reviewRequestCode"; + + private final ReviewGroupService reviewGroupService; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(ReviewGroupSession.class); + } + + @Override + public ReviewGroup resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class); + HttpSession session = request.getSession(false); + + // 세션이 없거나, 세션 안에 reviewRequestCode가 존재하지 않는 경우 + if (session == null) { + throw new ReviewGroupSessionNotFoundException(); + } + String reviewRequestCode = (String) session.getAttribute(SESSION_KEY); + if (reviewRequestCode == null) { + throw new ReviewGroupSessionNotFoundException(); + } + return reviewGroupService.getReviewGroupByReviewRequestCode(reviewRequestCode); + } +} diff --git a/backend/src/main/java/reviewme/reviewgroup/domain/ReviewGroup.java b/backend/src/main/java/reviewme/reviewgroup/domain/ReviewGroup.java index dedd25b60..dcc97fefe 100644 --- a/backend/src/main/java/reviewme/reviewgroup/domain/ReviewGroup.java +++ b/backend/src/main/java/reviewme/reviewgroup/domain/ReviewGroup.java @@ -8,6 +8,7 @@ import jakarta.persistence.Id; import jakarta.persistence.Table; import lombok.AccessLevel; +import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NoArgsConstructor; import reviewme.review.domain.exception.InvalidProjectNameLengthException; @@ -16,6 +17,7 @@ @Entity @Table(name = "review_group") @NoArgsConstructor(access = AccessLevel.PROTECTED) +@EqualsAndHashCode(of = "id") @Getter public class ReviewGroup { diff --git a/backend/src/main/java/reviewme/reviewgroup/service/ReviewGroupService.java b/backend/src/main/java/reviewme/reviewgroup/service/ReviewGroupService.java index e8e4ac0ba..1ae76f6a0 100644 --- a/backend/src/main/java/reviewme/reviewgroup/service/ReviewGroupService.java +++ b/backend/src/main/java/reviewme/reviewgroup/service/ReviewGroupService.java @@ -52,4 +52,10 @@ public void checkGroupAccessCode(CheckValidAccessRequest request) { throw new ReviewGroupUnauthorizedException(reviewGroup.getId()); } } + + @Transactional(readOnly = true) + public ReviewGroup getReviewGroupByReviewRequestCode(String reviewRequestCode) { + return reviewGroupRepository.findByReviewRequestCode(reviewRequestCode) + .orElseThrow(() -> new ReviewGroupNotFoundByReviewRequestCodeException(reviewRequestCode)); + } } diff --git a/backend/src/main/java/reviewme/template/controller/SectionController.java b/backend/src/main/java/reviewme/template/controller/SectionController.java index dccd61468..23826d87f 100644 --- a/backend/src/main/java/reviewme/template/controller/SectionController.java +++ b/backend/src/main/java/reviewme/template/controller/SectionController.java @@ -4,7 +4,8 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.bind.annotation.SessionAttribute; +import reviewme.reviewgroup.controller.ReviewGroupSession; +import reviewme.reviewgroup.domain.ReviewGroup; import reviewme.template.service.SectionService; import reviewme.template.service.dto.response.SectionNamesResponse; @@ -16,9 +17,9 @@ public class SectionController { @GetMapping("/v2/sections") public ResponseEntity getSectionNames( - @SessionAttribute("reviewRequestCode") String reviewRequestCode + @ReviewGroupSession ReviewGroup reviewGroup ) { - SectionNamesResponse sectionNames = sectionService.getSectionNames(reviewRequestCode); + SectionNamesResponse sectionNames = sectionService.getSectionNames(reviewGroup); return ResponseEntity.ok(sectionNames); } } diff --git a/backend/src/main/java/reviewme/template/service/SectionService.java b/backend/src/main/java/reviewme/template/service/SectionService.java index 90bc7d1f7..e52347042 100644 --- a/backend/src/main/java/reviewme/template/service/SectionService.java +++ b/backend/src/main/java/reviewme/template/service/SectionService.java @@ -4,9 +4,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import reviewme.review.service.exception.ReviewGroupNotFoundByReviewRequestCodeException; import reviewme.reviewgroup.domain.ReviewGroup; -import reviewme.reviewgroup.repository.ReviewGroupRepository; import reviewme.template.repository.SectionRepository; import reviewme.template.service.dto.response.SectionNameResponse; import reviewme.template.service.dto.response.SectionNamesResponse; @@ -15,15 +13,12 @@ @RequiredArgsConstructor public class SectionService { - private final ReviewGroupRepository reviewGroupRepository; private final SectionRepository sectionRepository; @Transactional(readOnly = true) - public SectionNamesResponse getSectionNames(String reviewRequestCode) { - ReviewGroup reviewGroup = reviewGroupRepository.findByReviewRequestCode(reviewRequestCode) - .orElseThrow(() -> new ReviewGroupNotFoundByReviewRequestCodeException(reviewRequestCode)); - - List sectionNameResponses = sectionRepository.findAllByTemplateId(reviewGroup.getTemplateId()) + public SectionNamesResponse getSectionNames(ReviewGroup reviewGroup) { + List sectionNameResponses = sectionRepository.findAllByTemplateId( + reviewGroup.getTemplateId()) .stream() .map(section -> new SectionNameResponse(section.getId(), section.getSectionName())) .toList(); diff --git a/backend/src/test/java/reviewme/api/ApiTest.java b/backend/src/test/java/reviewme/api/ApiTest.java index 463e5f08e..72eb11e02 100644 --- a/backend/src/test/java/reviewme/api/ApiTest.java +++ b/backend/src/test/java/reviewme/api/ApiTest.java @@ -35,6 +35,7 @@ import reviewme.review.service.ReviewRegisterService; import reviewme.review.service.ReviewSummaryService; import reviewme.reviewgroup.controller.ReviewGroupController; +import reviewme.reviewgroup.controller.ReviewGroupSessionResolver; import reviewme.reviewgroup.service.ReviewGroupLookupService; import reviewme.reviewgroup.service.ReviewGroupService; import reviewme.template.controller.SectionController; @@ -84,6 +85,9 @@ public abstract class ApiTest { @MockBean protected HighlightService highlightService; + @MockBean + private ReviewGroupSessionResolver reviewGroupSessionResolver; + Filter sessionCookieFilter = (request, response, chain) -> { chain.doFilter(request, response); HttpSession session = ((HttpServletRequest) request).getSession(false); diff --git a/backend/src/test/java/reviewme/api/ReviewApiTest.java b/backend/src/test/java/reviewme/api/ReviewApiTest.java index 06a39f63b..8df8fa70e 100644 --- a/backend/src/test/java/reviewme/api/ReviewApiTest.java +++ b/backend/src/test/java/reviewme/api/ReviewApiTest.java @@ -111,7 +111,7 @@ class ReviewApiTest extends ApiTest { @Test void 자신이_받은_리뷰_한_개를_조회한다() { - BDDMockito.given(reviewDetailLookupService.getReviewDetail(anyLong(), anyString())) + BDDMockito.given(reviewDetailLookupService.getReviewDetail(anyLong(), any())) .willReturn(TemplateFixture.templateAnswerResponse()); ParameterDescriptor[] requestPathDescriptors = { @@ -176,7 +176,7 @@ class ReviewApiTest extends ApiTest { ); ReceivedReviewsResponse response = new ReceivedReviewsResponse( "아루3", "리뷰미", 1L, true, receivedReviews); - BDDMockito.given(reviewListLookupService.getReceivedReviews(anyLong(), anyInt(), anyString())) + BDDMockito.given(reviewListLookupService.getReceivedReviews(anyLong(), anyInt(), any())) .willReturn(response); CookieDescriptor[] cookieDescriptors = { @@ -225,7 +225,7 @@ class ReviewApiTest extends ApiTest { @Test void 자신이_받은_리뷰의_요약를_조회한다() { - BDDMockito.given(reviewSummaryService.getReviewSummary(anyString())) + BDDMockito.given(reviewSummaryService.getReviewSummary(any())) .willReturn(new ReceivedReviewsSummaryResponse("리뷰미", "산초", 5)); CookieDescriptor[] cookieDescriptors = { @@ -271,7 +271,7 @@ class ReviewApiTest extends ApiTest { new VoteResponse("짜장", 3), new VoteResponse("짬뽕", 5)))) ); - BDDMockito.given(reviewGatheredLookupService.getReceivedReviewsBySectionId(anyString(), anyLong())) + BDDMockito.given(reviewGatheredLookupService.getReceivedReviewsBySectionId(any(), anyLong())) .willReturn(response); CookieDescriptor[] cookieDescriptors = { diff --git a/backend/src/test/java/reviewme/api/TemplateApiTest.java b/backend/src/test/java/reviewme/api/TemplateApiTest.java index 2fd462223..932039bac 100644 --- a/backend/src/test/java/reviewme/api/TemplateApiTest.java +++ b/backend/src/test/java/reviewme/api/TemplateApiTest.java @@ -1,5 +1,6 @@ package reviewme.api; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.springframework.restdocs.cookies.CookieDocumentation.cookieWithName; import static org.springframework.restdocs.cookies.CookieDocumentation.requestCookies; @@ -103,7 +104,7 @@ class TemplateApiTest extends ApiTest { new SectionNameResponse(1, "섹션1 이름"), new SectionNameResponse(2, "섹션2 이름") )); - BDDMockito.given(sectionService.getSectionNames(anyString())) + BDDMockito.given(sectionService.getSectionNames(any())) .willReturn(response); CookieDescriptor[] cookieDescriptors = { diff --git a/backend/src/test/java/reviewme/config/CorsConfigTest.java b/backend/src/test/java/reviewme/config/CorsConfigTest.java index c2ce590c1..90af4a342 100644 --- a/backend/src/test/java/reviewme/config/CorsConfigTest.java +++ b/backend/src/test/java/reviewme/config/CorsConfigTest.java @@ -3,11 +3,13 @@ import org.junit.jupiter.api.BeforeEach; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.stereotype.Controller; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.context.WebApplicationContext; +import reviewme.reviewgroup.service.ReviewGroupService; @WebMvcTest(controllers = CorsConfigTest.TestController.class) abstract class CorsConfigTest { @@ -15,6 +17,9 @@ abstract class CorsConfigTest { @Autowired private WebApplicationContext context; + @MockBean + private ReviewGroupService reviewGroupService; + protected MockMvc mockMvc; @BeforeEach diff --git a/backend/src/test/java/reviewme/config/ExternalCorsConfigTest.java b/backend/src/test/java/reviewme/config/ExternalCorsConfigTest.java index 81c04c76e..095bb1bc7 100644 --- a/backend/src/test/java/reviewme/config/ExternalCorsConfigTest.java +++ b/backend/src/test/java/reviewme/config/ExternalCorsConfigTest.java @@ -5,11 +5,13 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpHeaders; import org.springframework.test.context.ActiveProfiles; +@Disabled @ActiveProfiles("dev") class ExternalCorsConfigTest extends CorsConfigTest { diff --git a/backend/src/test/java/reviewme/config/LocalCorsConfigTest.java b/backend/src/test/java/reviewme/config/LocalCorsConfigTest.java index f04698d3f..cd050b988 100644 --- a/backend/src/test/java/reviewme/config/LocalCorsConfigTest.java +++ b/backend/src/test/java/reviewme/config/LocalCorsConfigTest.java @@ -5,10 +5,12 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.springframework.http.HttpHeaders; import org.springframework.test.context.ActiveProfiles; +@Disabled @ActiveProfiles("local") class LocalCorsConfigTest extends CorsConfigTest { diff --git a/backend/src/test/java/reviewme/global/HeaderPropertyArgumentResolverTest.java b/backend/src/test/java/reviewme/global/HeaderPropertyArgumentResolverTest.java deleted file mode 100644 index fdaae95df..000000000 --- a/backend/src/test/java/reviewme/global/HeaderPropertyArgumentResolverTest.java +++ /dev/null @@ -1,56 +0,0 @@ -package reviewme.global; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.mock; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.core.MethodParameter; -import org.springframework.mock.web.MockHttpServletRequest; -import org.springframework.web.context.request.NativeWebRequest; -import reviewme.global.exception.MissingHeaderPropertyException; - -class HeaderPropertyArgumentResolverTest { - - private final HeaderPropertyArgumentResolver resolver = new HeaderPropertyArgumentResolver(); - private final MethodParameter parameter = mock(MethodParameter.class); - private final HeaderProperty headerProperty = mock(HeaderProperty.class); - - @BeforeEach - void setUp() { - given(parameter.hasParameterAnnotation(HeaderProperty.class)).willReturn(true); - given(parameter.getParameterAnnotation(HeaderProperty.class)).willReturn(headerProperty); - } - - @Test - void 검증값이_헤더에_존재하지_않으면_검증에_실패한다() { - // given - NativeWebRequest request = mock(NativeWebRequest.class); - given(request.getNativeRequest()).willReturn(new MockHttpServletRequest()); - given(headerProperty.headerName()).willReturn("test"); - - // when, then - assertThatThrownBy(() -> resolver.resolveArgument(parameter, null, request, null)) - .isInstanceOf(MissingHeaderPropertyException.class); - } - - @Test - void 검증값이_헤더에_존재하면_값을_반환한다() { - // given - String headerName = "test"; - String headerValue = "1234"; - NativeWebRequest request = mock(NativeWebRequest.class); - MockHttpServletRequest mockRequest = (new MockHttpServletRequest()); - mockRequest.addHeader(headerName, headerValue); - given(request.getNativeRequest()).willReturn(mockRequest); - given(headerProperty.headerName()).willReturn(headerName); - - // when - String actual = resolver.resolveArgument(parameter, null, request, null); - - // then - assertThat(actual).isEqualTo(headerValue); - } -} diff --git a/backend/src/test/java/reviewme/highlight/service/HighlightServiceTest.java b/backend/src/test/java/reviewme/highlight/service/HighlightServiceTest.java index 0128b9d3e..a31403588 100644 --- a/backend/src/test/java/reviewme/highlight/service/HighlightServiceTest.java +++ b/backend/src/test/java/reviewme/highlight/service/HighlightServiceTest.java @@ -2,14 +2,14 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; +import static reviewme.fixture.QuestionFixture.서술형_필수_질문; +import static reviewme.fixture.ReviewGroupFixture.리뷰_그룹; import static reviewme.fixture.SectionFixture.항상_보이는_섹션; import static reviewme.fixture.TemplateFixture.템플릿; import java.util.List; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import reviewme.fixture.QuestionFixture; -import reviewme.fixture.ReviewGroupFixture; import reviewme.highlight.domain.Highlight; import reviewme.highlight.domain.HighlightPosition; import reviewme.highlight.repository.HighlightRepository; @@ -21,6 +21,7 @@ import reviewme.review.domain.Review; import reviewme.review.domain.TextAnswer; import reviewme.review.repository.ReviewRepository; +import reviewme.reviewgroup.domain.ReviewGroup; import reviewme.reviewgroup.repository.ReviewGroupRepository; import reviewme.support.ServiceTest; import reviewme.template.repository.SectionRepository; @@ -53,18 +54,18 @@ class HighlightServiceTest { @Test void 하이라이트_반영을_요청하면_리뷰_그룹과_질문에_해당하는_기존_하이라이트를_모두_삭제한다() { // given - long questionId = questionRepository.save(QuestionFixture.서술형_필수_질문()).getId(); + long questionId = questionRepository.save(서술형_필수_질문()).getId(); long sectionId = sectionRepository.save(항상_보이는_섹션(List.of(questionId))).getId(); long templateId = templateRepository.save(템플릿(List.of(sectionId))).getId(); String reviewRequestCode = "reviewRequestCode"; - long reviewGroupId = reviewGroupRepository.save(ReviewGroupFixture.리뷰_그룹(reviewRequestCode, "groupAccessCode")) - .getId(); + ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹(reviewRequestCode, "groupAccessCode")); Highlight highlight1 = highlightRepository.save(new Highlight(1, 1, 1, 1)); Highlight highlight2 = highlightRepository.save(new Highlight(2, 1, 1, 1)); TextAnswer textAnswer1 = new TextAnswer(questionId, "text answer1"); TextAnswer textAnswer2 = new TextAnswer(questionId, "text answer2"); - Review review = reviewRepository.save(new Review(templateId, reviewGroupId, List.of(textAnswer1, textAnswer2))); + Review review = reviewRepository.save( + new Review(templateId, reviewGroup.getId(), List.of(textAnswer1, textAnswer2))); HighlightIndexRangeRequest indexRangeRequest = new HighlightIndexRangeRequest(1, 1); HighlightedLineRequest lineRequest = new HighlightedLineRequest(0, List.of(indexRangeRequest)); @@ -75,7 +76,7 @@ class HighlightServiceTest { ); // when - highlightService.highlight(highlightsRequest, reviewRequestCode); + highlightService.highlight(highlightsRequest, reviewGroup); // then assertAll( @@ -87,17 +88,16 @@ class HighlightServiceTest { @Test void 하이라이트_반영을_요청하면_새로운_하이라이트가_저장된다() { // given - long questionId = questionRepository.save(QuestionFixture.서술형_필수_질문()).getId(); + long questionId = questionRepository.save(서술형_필수_질문()).getId(); long sectionId = sectionRepository.save(항상_보이는_섹션(List.of(questionId))).getId(); long templateId = templateRepository.save(템플릿(List.of(sectionId))).getId(); String reviewRequestCode = "reviewRequestCode"; - long reviewGroupId = reviewGroupRepository.save(ReviewGroupFixture.리뷰_그룹(reviewRequestCode, "groupAccessCode")) - .getId(); + ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹(reviewRequestCode, "groupAccessCode")); highlightRepository.save(new Highlight(1, 1, 1, 1)); TextAnswer textAnswer1 = new TextAnswer(questionId, "text answer1"); TextAnswer textAnswer2 = new TextAnswer(questionId, "text answer2"); - Review review = reviewRepository.save(new Review(templateId, reviewGroupId, List.of(textAnswer1, textAnswer2))); + Review review = reviewRepository.save(new Review(templateId, reviewGroup.getId(), List.of(textAnswer1, textAnswer2))); int startIndex = 2; int endIndex = 2; @@ -111,7 +111,7 @@ class HighlightServiceTest { List.of(highlightRequest1, highlightRequest2)); // when - highlightService.highlight(highlightsRequest, reviewRequestCode); + highlightService.highlight(highlightsRequest, reviewGroup); // then List highlights = highlightRepository.findAll(); diff --git a/backend/src/test/java/reviewme/review/service/ReviewDetailLookupServiceTest.java b/backend/src/test/java/reviewme/review/service/ReviewDetailLookupServiceTest.java index 94f281594..ab296d796 100644 --- a/backend/src/test/java/reviewme/review/service/ReviewDetailLookupServiceTest.java +++ b/backend/src/test/java/reviewme/review/service/ReviewDetailLookupServiceTest.java @@ -31,7 +31,6 @@ import reviewme.review.service.dto.response.detail.QuestionAnswerResponse; import reviewme.review.service.dto.response.detail.ReviewDetailResponse; import reviewme.review.service.dto.response.detail.SectionAnswerResponse; -import reviewme.review.service.exception.ReviewGroupNotFoundByReviewRequestCodeException; import reviewme.review.service.exception.ReviewNotFoundByIdAndGroupException; import reviewme.reviewgroup.domain.ReviewGroup; import reviewme.reviewgroup.repository.ReviewGroupRepository; @@ -68,20 +67,6 @@ class ReviewDetailLookupServiceTest { @Autowired private TemplateRepository templateRepository; - @Test - void 잘못된_리뷰_요청_코드로_리뷰를_조회할_경우_예외가_발생한다() { - // given - String reviewRequestCode = "hello"; - String groupAccessCode = "goodBye"; - ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹(reviewRequestCode, groupAccessCode)); - Review review = reviewRepository.save(new Review(0, reviewGroup.getId(), List.of())); - - // when, then - assertThatThrownBy(() -> reviewDetailLookupService.getReviewDetail( - review.getId(), "wrong" + reviewRequestCode - )).isInstanceOf(ReviewGroupNotFoundByReviewRequestCodeException.class); - } - @Test void 리뷰_그룹에_해당하지_않는_리뷰를_조회할_경우_예외가_발생한다() { // given @@ -97,12 +82,10 @@ class ReviewDetailLookupServiceTest { // when, then assertAll( - () -> assertThatThrownBy(() -> reviewDetailLookupService.getReviewDetail( - review2.getId(), reviewRequestCode1 - )).isInstanceOf(ReviewNotFoundByIdAndGroupException.class), - () -> assertThatThrownBy(() -> reviewDetailLookupService.getReviewDetail( - review1.getId(), reviewRequestCode2 - )).isInstanceOf(ReviewNotFoundByIdAndGroupException.class) + () -> assertThatThrownBy(() -> reviewDetailLookupService.getReviewDetail(review2.getId(), reviewGroup1)) + .isInstanceOf(ReviewNotFoundByIdAndGroupException.class), + () -> assertThatThrownBy(() -> reviewDetailLookupService.getReviewDetail(review1.getId(), reviewGroup2)) + .isInstanceOf(ReviewNotFoundByIdAndGroupException.class) ); } @@ -135,9 +118,7 @@ class ReviewDetailLookupServiceTest { ); // when - ReviewDetailResponse reviewDetail = reviewDetailLookupService.getReviewDetail( - review.getId(), reviewRequestCode - ); + ReviewDetailResponse reviewDetail = reviewDetailLookupService.getReviewDetail(review.getId(), reviewGroup); // then assertThat(reviewDetail.sections()).hasSize(2); @@ -165,9 +146,7 @@ class NotAnsweredOptionalQuestion { ); // when - ReviewDetailResponse reviewDetail = reviewDetailLookupService.getReviewDetail( - review.getId(), reviewRequestCode - ); + ReviewDetailResponse reviewDetail = reviewDetailLookupService.getReviewDetail(review.getId(), reviewGroup); // then assertThat(reviewDetail.sections()) @@ -195,9 +174,7 @@ class NotAnsweredOptionalQuestion { ); // when - ReviewDetailResponse reviewDetail = reviewDetailLookupService.getReviewDetail( - review.getId(), reviewRequestCode - ); + ReviewDetailResponse reviewDetail = reviewDetailLookupService.getReviewDetail(review.getId(), reviewGroup); // then assertAll( diff --git a/backend/src/test/java/reviewme/review/service/ReviewGatheredLookupServiceTest.java b/backend/src/test/java/reviewme/review/service/ReviewGatheredLookupServiceTest.java index 7a378adb5..409ac1210 100644 --- a/backend/src/test/java/reviewme/review/service/ReviewGatheredLookupServiceTest.java +++ b/backend/src/test/java/reviewme/review/service/ReviewGatheredLookupServiceTest.java @@ -99,7 +99,8 @@ class GatherAnswerByQuestionTest { // when ReviewsGatheredBySectionResponse actual = reviewLookupService.getReceivedReviewsBySectionId( - reviewRequestCode, section1.getId()); + reviewGroup, section1.getId() + ); // then assertThat(actual.reviews().get(0).answers()).extracting(TextResponse::content) @@ -126,7 +127,8 @@ class GatherAnswerByQuestionTest { // when ReviewsGatheredBySectionResponse actual = reviewLookupService.getReceivedReviewsBySectionId( - reviewRequestCode, section1.getId()); + reviewGroup, section1.getId() + ); // then assertThat(actual.reviews().get(0).answers()) @@ -158,7 +160,8 @@ class GatherAnswerByQuestionTest { // when ReviewsGatheredBySectionResponse actual = reviewLookupService.getReceivedReviewsBySectionId( - reviewRequestCode, section1.getId()); + reviewGroup, section1.getId() + ); // then assertThat(actual.reviews().get(0).answers()) @@ -185,7 +188,8 @@ class GatherAnswerByQuestionTest { // when ReviewsGatheredBySectionResponse actual = reviewLookupService.getReceivedReviewsBySectionId( - reviewRequestCode, section1.getId()); + reviewGroup, section1.getId() + ); // then assertThat(actual.reviews().get(0).answers()) @@ -207,7 +211,8 @@ class GatherAnswerByQuestionTest { // when ReviewsGatheredBySectionResponse actual = reviewLookupService.getReceivedReviewsBySectionId( - reviewRequestCode, section1.getId()); + reviewGroup, section1.getId() + ); // then assertThat(actual.reviews()).hasSize(1); @@ -244,7 +249,8 @@ class GatherOptionAnswerByQuestionTest { // when ReviewsGatheredBySectionResponse actual = reviewLookupService.getReceivedReviewsBySectionId( - reviewRequestCode, section1.getId()); + reviewGroup, section1.getId() + ); // then assertThat(actual.reviews().get(0).votes()) @@ -278,7 +284,8 @@ class GatherOptionAnswerByQuestionTest { // when ReviewsGatheredBySectionResponse actual = reviewLookupService.getReceivedReviewsBySectionId( - reviewRequestCode, section1.getId()); + reviewGroup, section1.getId() + ); // then assertThat(actual.reviews().get(0).votes()) @@ -311,7 +318,8 @@ class GatherOptionAnswerByQuestionTest { // when ReviewsGatheredBySectionResponse actual = reviewLookupService.getReceivedReviewsBySectionId( - reviewRequestCode, section1.getId()); + reviewGroup, section1.getId() + ); // then assertThat(actual.reviews().get(0).votes()) @@ -344,7 +352,8 @@ class GatherOptionAnswerByQuestionTest { // when ReviewsGatheredBySectionResponse actual = reviewLookupService.getReceivedReviewsBySectionId( - reviewRequestCode, section1.getId()); + reviewGroup, section1.getId() + ); // then assertThat(actual.reviews()).hasSize(2); @@ -387,7 +396,7 @@ class GatherOptionAnswerByQuestionTest { // when ReviewsGatheredBySectionResponse actual = reviewLookupService.getReceivedReviewsBySectionId( - reviewRequestCodeBE, section1.getId()); + reviewGroupBE, section1.getId()); // then assertThat(actual.reviews()).hasSize(1); diff --git a/backend/src/test/java/reviewme/review/service/ReviewListLookupServiceTest.java b/backend/src/test/java/reviewme/review/service/ReviewListLookupServiceTest.java index ab55e11ac..d8384afe5 100644 --- a/backend/src/test/java/reviewme/review/service/ReviewListLookupServiceTest.java +++ b/backend/src/test/java/reviewme/review/service/ReviewListLookupServiceTest.java @@ -1,7 +1,6 @@ package reviewme.review.service; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertAll; import static reviewme.fixture.OptionGroupFixture.선택지_그룹; import static reviewme.fixture.OptionItemFixture.선택지; @@ -24,7 +23,6 @@ import reviewme.review.domain.TextAnswer; import reviewme.review.repository.ReviewRepository; import reviewme.review.service.dto.response.list.ReceivedReviewsResponse; -import reviewme.review.service.exception.ReviewGroupNotFoundByReviewRequestCodeException; import reviewme.reviewgroup.domain.ReviewGroup; import reviewme.reviewgroup.repository.ReviewGroupRepository; import reviewme.support.ServiceTest; @@ -60,12 +58,6 @@ class ReviewListLookupServiceTest { @Autowired private ReviewRepository reviewRepository; - @Test - void 리뷰_요청_코드가_존재하지_않는_경우_예외가_발생한다() { - assertThatThrownBy(() -> reviewListLookupService.getReceivedReviews(Long.MAX_VALUE, 5, "abc")) - .isInstanceOf(ReviewGroupNotFoundByReviewRequestCodeException.class); - } - @Test void 확인_코드에_해당하는_그룹이_존재하면_내가_받은_리뷰_목록을_반환한다() { // given - 리뷰 그룹 저장 @@ -91,7 +83,8 @@ class ReviewListLookupServiceTest { // when ReceivedReviewsResponse response = reviewListLookupService.getReceivedReviews( - Long.MAX_VALUE, 5, reviewRequestCode); + Long.MAX_VALUE, 5, reviewGroup + ); // then assertAll( @@ -124,7 +117,7 @@ class ReviewListLookupServiceTest { // when ReceivedReviewsResponse response - = reviewListLookupService.getReceivedReviews(Long.MAX_VALUE, 2, reviewRequestCode); + = reviewListLookupService.getReceivedReviews(Long.MAX_VALUE, 2, reviewGroup); // then assertAll( diff --git a/backend/src/test/java/reviewme/review/service/ReviewSummaryServiceTest.java b/backend/src/test/java/reviewme/review/service/ReviewSummaryServiceTest.java index 2a6e56088..2a2ffa7a5 100644 --- a/backend/src/test/java/reviewme/review/service/ReviewSummaryServiceTest.java +++ b/backend/src/test/java/reviewme/review/service/ReviewSummaryServiceTest.java @@ -1,7 +1,6 @@ package reviewme.review.service; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertAll; import static reviewme.fixture.QuestionFixture.서술형_필수_질문; import static reviewme.fixture.ReviewGroupFixture.리뷰_그룹; @@ -16,7 +15,6 @@ import reviewme.review.domain.Review; import reviewme.review.repository.ReviewRepository; import reviewme.review.service.dto.response.list.ReceivedReviewsSummaryResponse; -import reviewme.review.service.exception.ReviewGroupNotFoundByReviewRequestCodeException; import reviewme.reviewgroup.domain.ReviewGroup; import reviewme.reviewgroup.repository.ReviewGroupRepository; import reviewme.support.ServiceTest; @@ -65,8 +63,7 @@ class ReviewSummaryServiceTest { reviewRepository.save(new Review(template.getId(), reviewGroup2.getId(), List.of())); // when - ReceivedReviewsSummaryResponse actual = reviewSummaryService.getReviewSummary( - reviewGroup1.getReviewRequestCode()); + ReceivedReviewsSummaryResponse actual = reviewSummaryService.getReviewSummary(reviewGroup1); // then assertAll( @@ -75,15 +72,4 @@ class ReviewSummaryServiceTest { () -> assertThat(actual.totalReviewCount()).isEqualTo(reviews.size()) ); } - - @Test - void 리뷰_요약_정보_조회시_리뷰_요청_코드가_존재하지_않는_경우_예외가_발생한다() { - // given - ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹()); - - // when, then - assertThatThrownBy(() -> reviewSummaryService.getReviewSummary( - reviewGroup.getReviewRequestCode() + "wrong")) - .isInstanceOf(ReviewGroupNotFoundByReviewRequestCodeException.class); - } } diff --git a/backend/src/test/java/reviewme/reviewgroup/controller/ReviewGroupSessionResolverTest.java b/backend/src/test/java/reviewme/reviewgroup/controller/ReviewGroupSessionResolverTest.java new file mode 100644 index 000000000..d173983bb --- /dev/null +++ b/backend/src/test/java/reviewme/reviewgroup/controller/ReviewGroupSessionResolverTest.java @@ -0,0 +1,71 @@ +package reviewme.reviewgroup.controller; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.web.context.request.NativeWebRequest; +import reviewme.reviewgroup.service.ReviewGroupService; + +class ReviewGroupSessionResolverTest { + + private final ReviewGroupService reviewGroupService = mock(ReviewGroupService.class); + + private ReviewGroupSessionResolver reviewGroupSessionResolver; + + @BeforeEach + void setUp() { + reviewGroupSessionResolver = new ReviewGroupSessionResolver(reviewGroupService); + } + + @Test + void 세션에서_코드를_가져와_리뷰그룹으로_변환한다() { + // given + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpSession session = new MockHttpSession(); + session.setAttribute("reviewRequestCode", "abcd"); + request.setSession(session); + + NativeWebRequest nativeWebRequest = mock(NativeWebRequest.class); + given(nativeWebRequest.getNativeRequest(HttpServletRequest.class)).willReturn(request); + + // when + assertDoesNotThrow(() -> reviewGroupSessionResolver.resolveArgument( + null, null, nativeWebRequest, null + )); + } + + @Test + void 세션이_존재하지_않는_경우_예외를_발생한다() { + // given + MockHttpServletRequest request = new MockHttpServletRequest(); + NativeWebRequest nativeWebRequest = mock(NativeWebRequest.class); + given(nativeWebRequest.getNativeRequest(HttpServletRequest.class)).willReturn(request); + + // when, then + assertThatThrownBy(() -> reviewGroupSessionResolver.resolveArgument( + null, null, nativeWebRequest, null + )).isInstanceOf(ReviewGroupSessionNotFoundException.class); + } + + @Test + void 세션에_코드가_없는_경우_예외를_발생한다() { + // given + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpSession session = new MockHttpSession(); + request.setSession(session); + NativeWebRequest nativeWebRequest = mock(NativeWebRequest.class); + given(nativeWebRequest.getNativeRequest(HttpServletRequest.class)).willReturn(request); + + // when, then + assertThatThrownBy(() -> reviewGroupSessionResolver.resolveArgument( + null, null, nativeWebRequest, null + )).isInstanceOf(ReviewGroupSessionNotFoundException.class); + } +} diff --git a/backend/src/test/java/reviewme/reviewgroup/service/ReviewGroupServiceTest.java b/backend/src/test/java/reviewme/reviewgroup/service/ReviewGroupServiceTest.java index 2694da49c..6bdc22f44 100644 --- a/backend/src/test/java/reviewme/reviewgroup/service/ReviewGroupServiceTest.java +++ b/backend/src/test/java/reviewme/reviewgroup/service/ReviewGroupServiceTest.java @@ -17,7 +17,9 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.mock.mockito.MockBean; +import reviewme.review.service.exception.ReviewGroupNotFoundByReviewRequestCodeException; import reviewme.review.service.exception.ReviewGroupUnauthorizedException; +import reviewme.reviewgroup.domain.ReviewGroup; import reviewme.reviewgroup.repository.ReviewGroupRepository; import reviewme.reviewgroup.service.dto.CheckValidAccessRequest; import reviewme.reviewgroup.service.dto.ReviewGroupCreationRequest; @@ -77,4 +79,27 @@ class ReviewGroupServiceTest { .isInstanceOf(ReviewGroupUnauthorizedException.class) ); } + + @Test + void 리뷰_요청_코드로_리뷰_그룹을_반환한다() { + // given + String reviewRequestCode = "reviewRequestCode"; + ReviewGroup savedReviewGroup = reviewGroupRepository.save(리뷰_그룹(reviewRequestCode, "groupAccessCode")); + + // when + ReviewGroup actual = reviewGroupService.getReviewGroupByReviewRequestCode(reviewRequestCode); + + // then + assertThat(actual).isEqualTo(savedReviewGroup); + } + + @Test + void 리뷰_요청_코드로_리뷰_그룹을_찾을_수_없는_경우_예외가_발생한다() { + // given + String reviewRequestCode = "reviewRequestCode"; + + // when, then + assertThatThrownBy(() -> reviewGroupService.getReviewGroupByReviewRequestCode(reviewRequestCode)) + .isInstanceOf(ReviewGroupNotFoundByReviewRequestCodeException.class); + } } diff --git a/backend/src/test/java/reviewme/template/service/SectionServiceTest.java b/backend/src/test/java/reviewme/template/service/SectionServiceTest.java index 9ce12b2a5..c7e319d99 100644 --- a/backend/src/test/java/reviewme/template/service/SectionServiceTest.java +++ b/backend/src/test/java/reviewme/template/service/SectionServiceTest.java @@ -1,14 +1,12 @@ package reviewme.template.service; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; import static reviewme.fixture.ReviewGroupFixture.리뷰_그룹; import static reviewme.fixture.TemplateFixture.템플릿; import java.util.List; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import reviewme.review.service.exception.ReviewGroupNotFoundByReviewRequestCodeException; import reviewme.reviewgroup.domain.ReviewGroup; import reviewme.reviewgroup.repository.ReviewGroupRepository; import reviewme.support.ServiceTest; @@ -53,21 +51,10 @@ class SectionServiceTest { ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹()); // when - SectionNamesResponse actual = sectionService.getSectionNames(reviewGroup.getReviewRequestCode()); + SectionNamesResponse actual = sectionService.getSectionNames(reviewGroup); // then assertThat(actual.sections()).extracting(SectionNameResponse::name) .containsExactly(sectionName1, sectionName2, sectionName3); } - - @Test - void 템플릿에_있는_섹션_이름_목록_조회시_리뷰_요청_코드가_존재하지_않는_경우_예외가_발생한다() { - // given - ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹()); - - // when, then - assertThatThrownBy(() -> sectionService.getSectionNames( - reviewGroup.getReviewRequestCode() + "wrong")) - .isInstanceOf(ReviewGroupNotFoundByReviewRequestCodeException.class); - } } From 2bd019726e40dc0fe53689172606c69cb76c6200 Mon Sep 17 00:00:00 2001 From: Yeongseo Na Date: Tue, 15 Oct 2024 16:32:14 +0900 Subject: [PATCH 21/49] =?UTF-8?q?[BE]=20feat:=20=EC=A4=91=EB=B3=B5=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20=EB=B0=A9=EC=A7=80=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#781)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * build: 의존성 추가 * chore: 개발 환경 redis 설정 * feat: 레디스 설정을 빈으로 등록 * feat: 중복 요청 처리 인터셉터 생성 - URI, IP, UserAgent 를 기준으로 1초에 3개보다 많은 요청이 오는 경우, 429를 응답한다. 즉, 4번째 요청부터 거부된다. * feat: 중복 요청 처리 인터셉터 등록 * feat: 예외 핸들러 추가 * test: 중복 예외 처리 인터셉터 테스트 작성 * refactor: value를 업데이트할 때 만료 시간을 다시 설정하도록 수정 * style: 가독성을 위한 개행 수정 * style: 파일 끝 개행 * refactor: ConfigurationProperties 적용 * refactor: 변수명 변경 * refactor: 기존 상수 적용 * refactor: RedisTemplate 타입과 중복 요청 감지 로직 변경 - RedisTemplate -> RedisTemplate * refactor: 로그 메세지 수정 * chore: 사용하지 않는 예외 삭제 * refactor: 로그 레벨 수정 - info -> warn * refactor: 중복 요청 검증 로직 개션 - if null 분기 제거 * test: 깨진 테스트 봉합 * test: 의미 없어진 테스트 삭제 - 명시적으로 null 인 경우 1로 초기화했던 이전 코드와 달리, setIfAbsent를 통해서 값이 초기화하는 지금은 '1로 초기화되었는지' 검증하기가 어렵다. * refactor: 불필요한 지역변수 할당 삭제 - increment 가 증가한 결과를 바로 반환한다. 이를 사용하도록 수정했다. * test: redisTemplate 등록으로 깨지는 테스트 봉합 - RedisTemplate과 ValueOperations를 모킹한다. - 구체적인 인자를 지정하지 않은 stub 문을 수정한다. * refactor: 예외 이름 변경 * refactor: 요청 제한 설정을 properties로 이동 * refactor: 예외 메세지 수정 - 테드 의견 반영 * chore: 안쓰는 import 문 제거 * test: 테스트 데이터 경계값으로 변경 * refactor: 제한 만료 시간 설정 로직 변경 * refactor: frequency -> requestCount 완전 대체 --- backend/build.gradle | 1 + .../config/RequestLimitProperties.java | 13 ++++ .../config/RequestLimitRedisConfig.java | 34 ++++++++ .../main/java/reviewme/config/WebConfig.java | 10 +++ .../global/GlobalExceptionHandler.java | 6 ++ .../global/RequestLimitInterceptor.java | 50 ++++++++++++ .../exception/TooManyRequestException.java | 12 +++ backend/src/main/resources/application.yml | 6 ++ .../src/test/java/reviewme/api/ApiTest.java | 17 ++++ .../test/java/reviewme/api/ReviewApiTest.java | 2 +- .../global/RequestLimitInterceptorTest.java | 78 +++++++++++++++++++ backend/src/test/resources/application.yml | 6 ++ 12 files changed, 234 insertions(+), 1 deletion(-) create mode 100644 backend/src/main/java/reviewme/config/RequestLimitProperties.java create mode 100644 backend/src/main/java/reviewme/config/RequestLimitRedisConfig.java create mode 100644 backend/src/main/java/reviewme/global/RequestLimitInterceptor.java create mode 100644 backend/src/main/java/reviewme/global/exception/TooManyRequestException.java create mode 100644 backend/src/test/java/reviewme/global/RequestLimitInterceptorTest.java diff --git a/backend/build.gradle b/backend/build.gradle index bd3a976cf..cea87d9cf 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -34,6 +34,7 @@ dependencies { implementation 'io.micrometer:micrometer-registry-prometheus' implementation 'org.flywaydb:flyway-core' implementation 'org.flywaydb:flyway-mysql' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' runtimeOnly 'com.h2database:h2' runtimeOnly 'com.mysql:mysql-connector-j' diff --git a/backend/src/main/java/reviewme/config/RequestLimitProperties.java b/backend/src/main/java/reviewme/config/RequestLimitProperties.java new file mode 100644 index 000000000..efea3b4f8 --- /dev/null +++ b/backend/src/main/java/reviewme/config/RequestLimitProperties.java @@ -0,0 +1,13 @@ +package reviewme.config; + +import java.time.Duration; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "request-limit") +public record RequestLimitProperties( + long threshold, + Duration duration, + String host, + int port +) { +} diff --git a/backend/src/main/java/reviewme/config/RequestLimitRedisConfig.java b/backend/src/main/java/reviewme/config/RequestLimitRedisConfig.java new file mode 100644 index 000000000..a8307db5f --- /dev/null +++ b/backend/src/main/java/reviewme/config/RequestLimitRedisConfig.java @@ -0,0 +1,34 @@ +package reviewme.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericToStringSerializer; + +@Configuration +@EnableConfigurationProperties(RequestLimitProperties.class) +@RequiredArgsConstructor +public class RequestLimitRedisConfig { + + private final RequestLimitProperties requestLimitProperties; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory( + requestLimitProperties.host(), requestLimitProperties.port() + ); + } + + @Bean + public RedisTemplate requestLimitRedisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + redisTemplate.setValueSerializer(new GenericToStringSerializer<>(Long.class)); + + return redisTemplate; + } +} diff --git a/backend/src/main/java/reviewme/config/WebConfig.java b/backend/src/main/java/reviewme/config/WebConfig.java index d855040f0..916ea5a41 100644 --- a/backend/src/main/java/reviewme/config/WebConfig.java +++ b/backend/src/main/java/reviewme/config/WebConfig.java @@ -3,8 +3,11 @@ import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import reviewme.global.RequestLimitInterceptor; import reviewme.reviewgroup.controller.ReviewGroupSessionResolver; import reviewme.reviewgroup.service.ReviewGroupService; @@ -13,9 +16,16 @@ public class WebConfig implements WebMvcConfigurer { private final ReviewGroupService reviewGroupService; + private final RedisTemplate redisTemplate; + private final RequestLimitProperties requestLimitProperties; @Override public void addArgumentResolvers(List resolvers) { resolvers.add(new ReviewGroupSessionResolver(reviewGroupService)); } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(new RequestLimitInterceptor(redisTemplate, requestLimitProperties)); + } } diff --git a/backend/src/main/java/reviewme/global/GlobalExceptionHandler.java b/backend/src/main/java/reviewme/global/GlobalExceptionHandler.java index 7724dd90e..9d4511618 100644 --- a/backend/src/main/java/reviewme/global/GlobalExceptionHandler.java +++ b/backend/src/main/java/reviewme/global/GlobalExceptionHandler.java @@ -22,6 +22,7 @@ import org.springframework.web.servlet.resource.NoResourceFoundException; import reviewme.global.exception.BadRequestException; import reviewme.global.exception.DataInconsistencyException; +import reviewme.global.exception.TooManyRequestException; import reviewme.global.exception.FieldErrorResponse; import reviewme.global.exception.NotFoundException; import reviewme.global.exception.UnauthorizedException; @@ -50,6 +51,11 @@ public ProblemDetail handleDataConsistencyException(DataInconsistencyException e return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, ex.getErrorMessage()); } + @ExceptionHandler(TooManyRequestException.class) + public ProblemDetail handleDuplicateRequestException(TooManyRequestException ex) { + return ProblemDetail.forStatusAndDetail(HttpStatus.TOO_MANY_REQUESTS, ex.getErrorMessage()); + } + @ExceptionHandler(Exception.class) public ProblemDetail handleException(Exception ex) { log.error("Internal server error has occurred", ex); diff --git a/backend/src/main/java/reviewme/global/RequestLimitInterceptor.java b/backend/src/main/java/reviewme/global/RequestLimitInterceptor.java new file mode 100644 index 000000000..b5747dfd1 --- /dev/null +++ b/backend/src/main/java/reviewme/global/RequestLimitInterceptor.java @@ -0,0 +1,50 @@ +package reviewme.global; + +import static org.springframework.http.HttpHeaders.USER_AGENT; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.http.HttpMethod; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; +import reviewme.config.RequestLimitProperties; +import reviewme.global.exception.TooManyRequestException; + +@Component +@EnableConfigurationProperties(RequestLimitProperties.class) +@RequiredArgsConstructor +public class RequestLimitInterceptor implements HandlerInterceptor { + + private final RedisTemplate redisTemplate; + private final RequestLimitProperties requestLimitProperties; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + if (!HttpMethod.POST.matches(request.getMethod())) { + return true; + } + + String key = generateRequestKey(request); + ValueOperations valueOperations = redisTemplate.opsForValue(); + valueOperations.setIfAbsent(key, 0L, requestLimitProperties.duration()); + redisTemplate.expire(key, requestLimitProperties.duration()); + + long requestCount = valueOperations.increment(key); + if (requestCount > requestLimitProperties.threshold()) { + throw new TooManyRequestException(key); + } + return true; + } + + private String generateRequestKey(HttpServletRequest request) { + String requestURI = request.getRequestURI(); + String remoteAddr = request.getRemoteAddr(); + String userAgent = request.getHeader(USER_AGENT); + + return String.format("RequestURI: %s, RemoteAddr: %s, UserAgent: %s", requestURI, remoteAddr, userAgent); + } +} diff --git a/backend/src/main/java/reviewme/global/exception/TooManyRequestException.java b/backend/src/main/java/reviewme/global/exception/TooManyRequestException.java new file mode 100644 index 000000000..4f26fee3e --- /dev/null +++ b/backend/src/main/java/reviewme/global/exception/TooManyRequestException.java @@ -0,0 +1,12 @@ +package reviewme.global.exception; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class TooManyRequestException extends ReviewMeException { + + public TooManyRequestException(String requestKey) { + super("짧은 시간 안에 너무 많은 동일한 요청이 일어났어요. 잠시 후 다시 시도해주세요."); + log.warn("Too many request received - request: {}", requestKey); + } +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 45df6e2cb..aa0160b1f 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -37,3 +37,9 @@ cors: allowed-origins: - http://localhost - https://localhost + +request-limit: + threshold: 3 + duration: 1s + host: localhost + port: 6379 diff --git a/backend/src/test/java/reviewme/api/ApiTest.java b/backend/src/test/java/reviewme/api/ApiTest.java index 72eb11e02..682a2ea18 100644 --- a/backend/src/test/java/reviewme/api/ApiTest.java +++ b/backend/src/test/java/reviewme/api/ApiTest.java @@ -1,5 +1,7 @@ package reviewme.api; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration; import static org.springframework.restdocs.operation.preprocess.Preprocessors.modifyHeaders; import static org.springframework.restdocs.operation.preprocess.Preprocessors.modifyUris; @@ -15,8 +17,11 @@ import org.apache.http.HttpHeaders; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; import org.springframework.http.MediaType; import org.springframework.restdocs.RestDocumentationContextProvider; import org.springframework.restdocs.RestDocumentationExtension; @@ -73,6 +78,12 @@ public abstract class ApiTest { @MockBean protected ReviewGroupLookupService reviewGroupLookupService; + @MockBean + protected RedisTemplate redisTemplate; + + @Mock + protected ValueOperations valueOperations; + @MockBean protected ReviewSummaryService reviewSummaryService; @@ -100,6 +111,12 @@ public abstract class ApiTest { } }; + @BeforeEach + void setUpRedisConfig() { + given(redisTemplate.opsForValue()).willReturn(valueOperations); + given(valueOperations.increment(anyString())).willReturn(1L); + } + @BeforeEach void setUpRestDocs(WebApplicationContext context, RestDocumentationContextProvider provider) { UriModifyingOperationPreprocessor uriModifier = modifyUris() diff --git a/backend/src/test/java/reviewme/api/ReviewApiTest.java b/backend/src/test/java/reviewme/api/ReviewApiTest.java index 8df8fa70e..5add4cfbd 100644 --- a/backend/src/test/java/reviewme/api/ReviewApiTest.java +++ b/backend/src/test/java/reviewme/api/ReviewApiTest.java @@ -85,7 +85,7 @@ class ReviewApiTest extends ApiTest { @Test void 리뷰_그룹_코드가_올바르지_않은_경우_예외가_발생한다() { BDDMockito.given(reviewRegisterService.registerReview(any(ReviewRegisterRequest.class))) - .willThrow(new ReviewGroupNotFoundByReviewRequestCodeException(anyString())); + .willThrow(new ReviewGroupNotFoundByReviewRequestCodeException("ABCD1234")); FieldDescriptor[] requestFieldDescriptors = { fieldWithPath("reviewRequestCode").description("리뷰 요청 코드"), diff --git a/backend/src/test/java/reviewme/global/RequestLimitInterceptorTest.java b/backend/src/test/java/reviewme/global/RequestLimitInterceptorTest.java new file mode 100644 index 000000000..998639691 --- /dev/null +++ b/backend/src/test/java/reviewme/global/RequestLimitInterceptorTest.java @@ -0,0 +1,78 @@ +package reviewme.global; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.springframework.http.HttpHeaders.USER_AGENT; + +import jakarta.servlet.http.HttpServletRequest; +import java.time.Duration; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import reviewme.config.RequestLimitProperties; +import reviewme.global.exception.TooManyRequestException; + +class RequestLimitInterceptorTest { + + private final HttpServletRequest request = mock(HttpServletRequest.class); + private final RedisTemplate redisTemplate = mock(RedisTemplate.class); + private final ValueOperations valueOperations = mock(ValueOperations.class); + private final RequestLimitProperties requestLimitProperties = mock(RequestLimitProperties.class); + private final RequestLimitInterceptor interceptor = new RequestLimitInterceptor(redisTemplate, requestLimitProperties); + private final String requestKey = "RequestURI: /api/v2/reviews, RemoteAddr: localhost, UserAgent: Postman"; + + @BeforeEach + void setUp() { + given(request.getMethod()).willReturn("POST"); + given(request.getRequestURI()).willReturn("/api/v2/reviews"); + given(request.getRemoteAddr()).willReturn("localhost"); + given(request.getHeader(USER_AGENT)).willReturn("Postman"); + + given(redisTemplate.opsForValue()).willReturn(valueOperations); + given(requestLimitProperties.duration()).willReturn(Duration.ofSeconds(1)); + given(requestLimitProperties.threshold()).willReturn(3L); + } + + @Test + void POST_요청이_아니면_통과한다() { + // given + HttpServletRequest request = mock(HttpServletRequest.class); + given(request.getMethod()).willReturn("GET"); + + // when + boolean result = interceptor.preHandle(request, null, null); + + // then + assertThat(result).isTrue(); + } + + @Test + void 특정_POST_요청이_처음이_아니며_최대_빈도보다_작을_경우_빈도를_1증가시킨다() { + // given + long requestCount = 1; + given(valueOperations.get(anyString())).willReturn(requestCount); + + // when + boolean result = interceptor.preHandle(request, null, null); + + // then + assertThat(result).isTrue(); + verify(valueOperations).increment(requestKey); + } + + @Test + void 특정_POST_요청이_처음이_아니며_최대_빈도보다_클_경우_예외를_발생시킨다() { + // given + long maxRequestCount = 3; + given(valueOperations.increment(anyString())).willReturn(maxRequestCount + 1); + + // when & then + assertThatThrownBy(() -> interceptor.preHandle(request, null, null)) + .isInstanceOf(TooManyRequestException.class); + } +} diff --git a/backend/src/test/resources/application.yml b/backend/src/test/resources/application.yml index 0c5a19c1e..f18542246 100644 --- a/backend/src/test/resources/application.yml +++ b/backend/src/test/resources/application.yml @@ -38,3 +38,9 @@ logging: cors: allowed-origins: - https://allowed-domain.com + +request-limit: + threshold: 3 + duration: 1s + host: localhost + port: 6379 From 27683bd402955ce9b11ef07c7960ff34e6ebf551 Mon Sep 17 00:00:00 2001 From: Hyeonji <110809927+skylar1220@users.noreply.github.com> Date: Wed, 16 Oct 2024 10:13:43 +0900 Subject: [PATCH 22/49] =?UTF-8?q?[BE]=20refactor:=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=EC=9D=84=20=ED=99=9C=EC=9A=A9=ED=95=9C=20=ED=95=98?= =?UTF-8?q?=EC=9D=B4=EB=9D=BC=EC=9D=B4=ED=8A=B8=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20=EC=88=98=EC=A0=95=20=EB=A1=9C=EC=A7=81=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=86=A0=EB=A7=81=20(#859)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 인덱스 범위 도메인명 변경 및 lineIndex 책임을 Highlight로 이동 * refactor: 하이라이트 한줄 도메인 추가 * refactor: 한 답변에 표시된 하이라이트 여러줄 도메인 추가 * refactor: dto 필드의 속성을 가져오는 메서드 추가 * feat: 답변 id에 해당하는 하이라이트를 삭제하는 메서드 추가 * refactor: 중복되는 검증 및 로직 제거 * refactor: 생성자 추가 * refactor: 하이라이트 객체 매핑을 위한 mapper 분리 * refactor: 하이라이트 서비스에 mapper 적용 및 삭제 로직 단순화 * refactor: 사용하지 않는 클래스 및 메서드 삭제 * refactor: 도메인과 엔티티 패키지 분리 * refactor: dto 메서드로 대체 * refactor: 하이라이트 라인 관련 예외 추상화 * refactor: 하이라이트 라인 도메인명 수정 * refactor: 필드명 수정 * refactor: 변수명 및 메서드명 수정 * refactor: 필드명 수정 및 공백 제거 * refactor: 생성자 체이닝 * refactor: 중복 제거 방식 및 반환 타입 변경 * refactor: 도메인에서 필요하지 않은 필드인 lineIndex 삭제 및 생성자 수정 * refactor: 세션을 통해 리뷰 그룹 가져오기 적용 * refactor: 메서드 분리 * refactor: 사용하지 않는 생성자 제거 * refactor: 도메인명 변경 * refactor: 엔티티 패키지 삭제 및 도메인 패키지로 통 * refactor: 빈 하이라이트 요청이 왔을 때 아무것도 저장하지 않는 것 검증하는 테스트 추가 * refactor: 변수명 수정 및 메서드 정렬, 개행 * refactor: 접근 제어자 추가 및 static import 제거 * refactor: 사용하지 않는 메서드 삭제 --- .../controller/HighlightController.java | 2 +- .../reviewme/highlight/domain/Highlight.java | 10 +- ...lightPosition.java => HighlightRange.java} | 19 ++-- .../highlight/domain/HighlightedLine.java | 32 ++++++ .../highlight/domain/HighlightedLines.java | 40 +++++++ ...ghlightIndexExceedLineLengthException.java | 14 +++ ...ightStartIndexExceedEndIndexException.java | 2 +- .../InvalidHighlightIndexRangeException.java | 13 +++ .../InvalidHighlightLineIndexException.java | 6 +- .../NegativeHighlightIndexException.java | 13 --- .../NegativeHighlightLineIndexException.java | 13 +++ .../repository/HighlightRepository.java | 10 ++ .../highlight/service/HighlightService.java | 38 ++----- .../service/dto/HighlightsRequest.java | 8 ++ .../HighlightDuplicatedException.java | 14 --- .../service/mapper/HighlightMapper.java | 77 +++++++++++++ .../service/validator/HighlightValidator.java | 66 ++--------- .../reviewme/review/domain/TextAnswers.java | 30 ----- .../review/repository/AnswerRepository.java | 8 -- .../domain/HighlightPositionTest.java | 23 ---- .../highlight/domain/HighlightedLineTest.java | 36 ++++++ .../domain/HighlightedLinesTest.java | 87 +++++++++++++++ .../highlight/entity/HighlightRangeTest.java | 22 ++++ .../service/HighlightServiceTest.java | 72 ++++++------ .../service/mapper/HighlightMapperTest.java | 104 ++++++++++++++++++ .../validator/HighlightValidatorTest.java | 68 +++++------- .../review/domain/TextAnswersTest.java | 50 --------- 27 files changed, 557 insertions(+), 320 deletions(-) rename backend/src/main/java/reviewme/highlight/domain/{HighlightPosition.java => HighlightRange.java} (52%) create mode 100644 backend/src/main/java/reviewme/highlight/domain/HighlightedLine.java create mode 100644 backend/src/main/java/reviewme/highlight/domain/HighlightedLines.java create mode 100644 backend/src/main/java/reviewme/highlight/domain/exception/HighlightIndexExceedLineLengthException.java create mode 100644 backend/src/main/java/reviewme/highlight/domain/exception/InvalidHighlightIndexRangeException.java rename backend/src/main/java/reviewme/highlight/{service => domain}/exception/InvalidHighlightLineIndexException.java (58%) delete mode 100644 backend/src/main/java/reviewme/highlight/domain/exception/NegativeHighlightIndexException.java create mode 100644 backend/src/main/java/reviewme/highlight/domain/exception/NegativeHighlightLineIndexException.java delete mode 100644 backend/src/main/java/reviewme/highlight/service/exception/HighlightDuplicatedException.java create mode 100644 backend/src/main/java/reviewme/highlight/service/mapper/HighlightMapper.java delete mode 100644 backend/src/main/java/reviewme/review/domain/TextAnswers.java delete mode 100644 backend/src/test/java/reviewme/highlight/domain/HighlightPositionTest.java create mode 100644 backend/src/test/java/reviewme/highlight/domain/HighlightedLineTest.java create mode 100644 backend/src/test/java/reviewme/highlight/domain/HighlightedLinesTest.java create mode 100644 backend/src/test/java/reviewme/highlight/entity/HighlightRangeTest.java create mode 100644 backend/src/test/java/reviewme/highlight/service/mapper/HighlightMapperTest.java delete mode 100644 backend/src/test/java/reviewme/review/domain/TextAnswersTest.java diff --git a/backend/src/main/java/reviewme/highlight/controller/HighlightController.java b/backend/src/main/java/reviewme/highlight/controller/HighlightController.java index a76c6c298..ed97f0b5e 100644 --- a/backend/src/main/java/reviewme/highlight/controller/HighlightController.java +++ b/backend/src/main/java/reviewme/highlight/controller/HighlightController.java @@ -22,7 +22,7 @@ public ResponseEntity highlight( @Valid @RequestBody HighlightsRequest request, @ReviewGroupSession ReviewGroup reviewGroup ) { - highlightService.highlight(request, reviewGroup); + highlightService.editHighlight(request, reviewGroup); return ResponseEntity.ok().build(); } } diff --git a/backend/src/main/java/reviewme/highlight/domain/Highlight.java b/backend/src/main/java/reviewme/highlight/domain/Highlight.java index de6714efa..4b30a79db 100644 --- a/backend/src/main/java/reviewme/highlight/domain/Highlight.java +++ b/backend/src/main/java/reviewme/highlight/domain/Highlight.java @@ -26,11 +26,15 @@ public class Highlight { @Column(name = "answer_id", nullable = false) private long answerId; + @Column(name = "line_index", nullable = false) + private int lineIndex; + @Embedded - private HighlightPosition highlightPosition; + private HighlightRange highlightRange; - public Highlight(long answerId, int lineIndex, int startIndex, int endIndex) { + public Highlight(long answerId, int lineIndex, HighlightRange range) { this.answerId = answerId; - this.highlightPosition = new HighlightPosition(lineIndex, startIndex, endIndex); + this.lineIndex = lineIndex; + this.highlightRange = range; } } diff --git a/backend/src/main/java/reviewme/highlight/domain/HighlightPosition.java b/backend/src/main/java/reviewme/highlight/domain/HighlightRange.java similarity index 52% rename from backend/src/main/java/reviewme/highlight/domain/HighlightPosition.java rename to backend/src/main/java/reviewme/highlight/domain/HighlightRange.java index 25463cd51..06fa9b5cd 100644 --- a/backend/src/main/java/reviewme/highlight/domain/HighlightPosition.java +++ b/backend/src/main/java/reviewme/highlight/domain/HighlightRange.java @@ -6,17 +6,13 @@ import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NoArgsConstructor; -import reviewme.highlight.domain.exception.HighlightStartIndexExceedEndIndexException; -import reviewme.highlight.domain.exception.NegativeHighlightIndexException; +import reviewme.highlight.domain.exception.InvalidHighlightIndexRangeException; @Embeddable @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter @EqualsAndHashCode -public class HighlightPosition { - - @Column(name = "line_index", nullable = false) - private int lineIndex; +public class HighlightRange { @Column(name = "start_index", nullable = false) private int startIndex; @@ -24,23 +20,22 @@ public class HighlightPosition { @Column(name = "end_index", nullable = false) private int endIndex; - public HighlightPosition(int lineIndex, int startIndex, int endIndex) { + public HighlightRange(int startIndex, int endIndex) { validateNonNegativeIndexNumber(startIndex, endIndex); validateEndIndexOverStartIndex(startIndex, endIndex); - this.lineIndex = lineIndex; this.startIndex = startIndex; this.endIndex = endIndex; } - private void validateNonNegativeIndexNumber(long startIndex, long endIndex) { + private void validateNonNegativeIndexNumber(int startIndex, int endIndex) { if (startIndex < 0 || endIndex < 0) { - throw new NegativeHighlightIndexException(startIndex, endIndex); + throw new InvalidHighlightIndexRangeException(startIndex, endIndex); } } - private void validateEndIndexOverStartIndex(long startIndex, long endIndex) { + private void validateEndIndexOverStartIndex(int startIndex, int endIndex) { if (startIndex > endIndex) { - throw new HighlightStartIndexExceedEndIndexException(startIndex, endIndex); + throw new InvalidHighlightIndexRangeException(startIndex, endIndex); } } } diff --git a/backend/src/main/java/reviewme/highlight/domain/HighlightedLine.java b/backend/src/main/java/reviewme/highlight/domain/HighlightedLine.java new file mode 100644 index 000000000..06bca5576 --- /dev/null +++ b/backend/src/main/java/reviewme/highlight/domain/HighlightedLine.java @@ -0,0 +1,32 @@ +package reviewme.highlight.domain; + +import java.util.HashSet; +import java.util.Set; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import reviewme.highlight.domain.exception.HighlightIndexExceedLineLengthException; + +@Getter +@EqualsAndHashCode +public class HighlightedLine { + + private final String content; + private final Set ranges; + + public HighlightedLine(String content) { + this.content = content; + this.ranges = new HashSet<>(); + } + + public void addRange(int startIndex, int endIndex) { + validateRangeByContentLength(startIndex, endIndex); + ranges.add(new HighlightRange(startIndex, endIndex)); + } + + private void validateRangeByContentLength(int startIndex, int endIndex) { + int contentLength = content.length(); + if (startIndex >= contentLength || endIndex >= contentLength) { + throw new HighlightIndexExceedLineLengthException(content.length(), startIndex, endIndex); + } + } +} diff --git a/backend/src/main/java/reviewme/highlight/domain/HighlightedLines.java b/backend/src/main/java/reviewme/highlight/domain/HighlightedLines.java new file mode 100644 index 000000000..f7000ecb2 --- /dev/null +++ b/backend/src/main/java/reviewme/highlight/domain/HighlightedLines.java @@ -0,0 +1,40 @@ +package reviewme.highlight.domain; + +import java.util.Arrays; +import java.util.List; +import lombok.Getter; +import reviewme.highlight.domain.exception.InvalidHighlightLineIndexException; +import reviewme.highlight.domain.exception.NegativeHighlightLineIndexException; + +@Getter +public class HighlightedLines { + + public static final String LINE_SEPARATOR = "\n"; + + private final List lines; + + public HighlightedLines(String content) { + this.lines = Arrays.stream(content.split(LINE_SEPARATOR)) + .map(HighlightedLine::new) + .toList(); + } + + public void addRange(int lineIndex, int startIndex, int endIndex) { + validateNonNegativeLineIndexNumber(lineIndex); + validateLineIndexRange(lineIndex); + HighlightedLine line = lines.get(lineIndex); + line.addRange(startIndex, endIndex); + } + + private void validateNonNegativeLineIndexNumber(int lineIndex) { + if (lineIndex < 0) { + throw new NegativeHighlightLineIndexException(lineIndex); + } + } + + private void validateLineIndexRange(int lineIndex) { + if (lineIndex >= lines.size()) { + throw new InvalidHighlightLineIndexException(lineIndex, lines.size()); + } + } +} diff --git a/backend/src/main/java/reviewme/highlight/domain/exception/HighlightIndexExceedLineLengthException.java b/backend/src/main/java/reviewme/highlight/domain/exception/HighlightIndexExceedLineLengthException.java new file mode 100644 index 000000000..1c631fd83 --- /dev/null +++ b/backend/src/main/java/reviewme/highlight/domain/exception/HighlightIndexExceedLineLengthException.java @@ -0,0 +1,14 @@ +package reviewme.highlight.domain.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.BadRequestException; + +@Slf4j +public class HighlightIndexExceedLineLengthException extends BadRequestException { + + public HighlightIndexExceedLineLengthException(int lineLength, int startIndex, int endIndex) { + super("하이라이트 위치가 텍스트의 범위를 벗어났어요."); + log.info("Highlight index exceed line length - lineLength: {}, startIndex: {}, endIndex: {}", + lineLength, startIndex, endIndex); + } +} diff --git a/backend/src/main/java/reviewme/highlight/domain/exception/HighlightStartIndexExceedEndIndexException.java b/backend/src/main/java/reviewme/highlight/domain/exception/HighlightStartIndexExceedEndIndexException.java index 735ba06b6..38c99ac9a 100644 --- a/backend/src/main/java/reviewme/highlight/domain/exception/HighlightStartIndexExceedEndIndexException.java +++ b/backend/src/main/java/reviewme/highlight/domain/exception/HighlightStartIndexExceedEndIndexException.java @@ -6,7 +6,7 @@ @Slf4j public class HighlightStartIndexExceedEndIndexException extends BadRequestException { - public HighlightStartIndexExceedEndIndexException(long startIndex, long endIndex) { + public HighlightStartIndexExceedEndIndexException(int startIndex, int endIndex) { super("하이라이트 끝 위치는 시작 위치보다 같거나 커야 해요."); log.info("Highlight start index exceed end index - startIndex: {}, endIndex: {}", startIndex, endIndex); } diff --git a/backend/src/main/java/reviewme/highlight/domain/exception/InvalidHighlightIndexRangeException.java b/backend/src/main/java/reviewme/highlight/domain/exception/InvalidHighlightIndexRangeException.java new file mode 100644 index 000000000..889bcd4fb --- /dev/null +++ b/backend/src/main/java/reviewme/highlight/domain/exception/InvalidHighlightIndexRangeException.java @@ -0,0 +1,13 @@ +package reviewme.highlight.domain.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.BadRequestException; + +@Slf4j +public class InvalidHighlightIndexRangeException extends BadRequestException { + + public InvalidHighlightIndexRangeException(int startIndex, int endIndex) { + super("유효하지 않은 하이라이트 위치에요. 하이라이트 시작 위치: %d, 종료 위치: %d".formatted(startIndex, endIndex)); + log.info("Highlight index is a negative number - startIndex: {}, endIndex: {}", startIndex, endIndex); + } +} diff --git a/backend/src/main/java/reviewme/highlight/service/exception/InvalidHighlightLineIndexException.java b/backend/src/main/java/reviewme/highlight/domain/exception/InvalidHighlightLineIndexException.java similarity index 58% rename from backend/src/main/java/reviewme/highlight/service/exception/InvalidHighlightLineIndexException.java rename to backend/src/main/java/reviewme/highlight/domain/exception/InvalidHighlightLineIndexException.java index 7271c08a3..8f06ce5b4 100644 --- a/backend/src/main/java/reviewme/highlight/service/exception/InvalidHighlightLineIndexException.java +++ b/backend/src/main/java/reviewme/highlight/domain/exception/InvalidHighlightLineIndexException.java @@ -1,4 +1,4 @@ -package reviewme.highlight.service.exception; +package reviewme.highlight.domain.exception; import lombok.extern.slf4j.Slf4j; import reviewme.global.exception.BadRequestException; @@ -6,8 +6,8 @@ @Slf4j public class InvalidHighlightLineIndexException extends BadRequestException { - public InvalidHighlightLineIndexException(long submittedLineIndex, long providedMaxLineIndex) { - super("줄 번호는 %d 이하여야해요.".formatted(providedMaxLineIndex)); + public InvalidHighlightLineIndexException(int submittedLineIndex, int providedMaxLineIndex) { + super("하이라이트 위치가 답변의 라인을 벗어났어요."); log.info("Line index is out of bound - maxIndex: {}, submittedLineIndex: {}", providedMaxLineIndex, submittedLineIndex); } diff --git a/backend/src/main/java/reviewme/highlight/domain/exception/NegativeHighlightIndexException.java b/backend/src/main/java/reviewme/highlight/domain/exception/NegativeHighlightIndexException.java deleted file mode 100644 index 9d2a32f00..000000000 --- a/backend/src/main/java/reviewme/highlight/domain/exception/NegativeHighlightIndexException.java +++ /dev/null @@ -1,13 +0,0 @@ -package reviewme.highlight.domain.exception; - -import lombok.extern.slf4j.Slf4j; -import reviewme.global.exception.BadRequestException; - -@Slf4j -public class NegativeHighlightIndexException extends BadRequestException { - - public NegativeHighlightIndexException(long startIndex, long endIndex) { - super("하이라이트 위치는 1 이상의 수이어야 해요."); - log.info("Highlight index is a negative number - startIndex: {}, endIndex: {}", startIndex, endIndex); - } -} diff --git a/backend/src/main/java/reviewme/highlight/domain/exception/NegativeHighlightLineIndexException.java b/backend/src/main/java/reviewme/highlight/domain/exception/NegativeHighlightLineIndexException.java new file mode 100644 index 000000000..1fbd8f6c3 --- /dev/null +++ b/backend/src/main/java/reviewme/highlight/domain/exception/NegativeHighlightLineIndexException.java @@ -0,0 +1,13 @@ +package reviewme.highlight.domain.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.BadRequestException; + +@Slf4j +public class NegativeHighlightLineIndexException extends BadRequestException { + + public NegativeHighlightLineIndexException(int lineIndex) { + super("하이라이트 할 라인의 위치는 0 이상의 수이어야 해요."); + log.info("Highlight index is a negative number - lineIndex: {}", lineIndex); + } +} diff --git a/backend/src/main/java/reviewme/highlight/repository/HighlightRepository.java b/backend/src/main/java/reviewme/highlight/repository/HighlightRepository.java index c914d9750..c7e3b5adf 100644 --- a/backend/src/main/java/reviewme/highlight/repository/HighlightRepository.java +++ b/backend/src/main/java/reviewme/highlight/repository/HighlightRepository.java @@ -1,7 +1,17 @@ package reviewme.highlight.repository; +import java.util.Collection; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; import reviewme.highlight.domain.Highlight; public interface HighlightRepository extends JpaRepository { + + @Modifying + @Query(""" + DELETE FROM Highlight h + WHERE h.answerId IN :answerIds + """) + void deleteAllByAnswerIds(Collection answerIds); } diff --git a/backend/src/main/java/reviewme/highlight/service/HighlightService.java b/backend/src/main/java/reviewme/highlight/service/HighlightService.java index 8f05d156b..7cb9f9c70 100644 --- a/backend/src/main/java/reviewme/highlight/service/HighlightService.java +++ b/backend/src/main/java/reviewme/highlight/service/HighlightService.java @@ -1,6 +1,5 @@ package reviewme.highlight.service; -import java.util.ArrayList; import java.util.List; import java.util.Set; import lombok.RequiredArgsConstructor; @@ -8,12 +7,9 @@ import org.springframework.transaction.annotation.Transactional; import reviewme.highlight.domain.Highlight; import reviewme.highlight.repository.HighlightRepository; -import reviewme.highlight.service.dto.HighlightIndexRangeRequest; -import reviewme.highlight.service.dto.HighlightRequest; -import reviewme.highlight.service.dto.HighlightedLineRequest; import reviewme.highlight.service.dto.HighlightsRequest; +import reviewme.highlight.service.mapper.HighlightMapper; import reviewme.highlight.service.validator.HighlightValidator; -import reviewme.review.domain.Answer; import reviewme.review.repository.AnswerRepository; import reviewme.reviewgroup.domain.ReviewGroup; @@ -25,36 +21,16 @@ public class HighlightService { private final AnswerRepository answerRepository; private final HighlightValidator highlightValidator; + private final HighlightMapper highlightMapper; @Transactional - public void highlight(HighlightsRequest request, ReviewGroup reviewGroup) { - long reviewGroupId = reviewGroup.getId(); - highlightValidator.validate(request, reviewGroupId); - deleteOldHighlight(request.questionId(), reviewGroupId); - saveNewHighlight(request); - } - - private void deleteOldHighlight(long questionId, long reviewGroupId) { - Set answersByReviewGroup = answerRepository.findAllByReviewGroupId(reviewGroupId); - List answersByReviewQuestion = answersByReviewGroup.stream() - .filter(answer -> answer.getQuestionId() == questionId) - .map(Answer::getId) - .toList(); + public void editHighlight(HighlightsRequest highlightsRequest, ReviewGroup reviewGroup) { + highlightValidator.validate(highlightsRequest, reviewGroup); + List highlights = highlightMapper.mapToHighlights(highlightsRequest); - highlightRepository.deleteAllById(answersByReviewQuestion); - } + Set answerIds = answerRepository.findIdsByQuestionId(highlightsRequest.questionId()); + highlightRepository.deleteAllByAnswerIds(answerIds); - private void saveNewHighlight(HighlightsRequest highlightsRequest) { - List highlights = new ArrayList<>(); - for (HighlightRequest highlight : highlightsRequest.highlights()) { - for (HighlightedLineRequest line : highlight.lines()) { - for (HighlightIndexRangeRequest range : line.ranges()) { - Highlight highLight = new Highlight(highlight.answerId(), - line.index(), range.startIndex(), range.endIndex()); - highlights.add(highLight); - } - } - } highlightRepository.saveAll(highlights); } } diff --git a/backend/src/main/java/reviewme/highlight/service/dto/HighlightsRequest.java b/backend/src/main/java/reviewme/highlight/service/dto/HighlightsRequest.java index 7b41526d9..b8f26cba6 100644 --- a/backend/src/main/java/reviewme/highlight/service/dto/HighlightsRequest.java +++ b/backend/src/main/java/reviewme/highlight/service/dto/HighlightsRequest.java @@ -12,4 +12,12 @@ public record HighlightsRequest( @Valid @NotNull(message = "하이라이트할 부분을 입력해주세요.") List highlights ) { + + public List getUniqueAnswerIds() { + return highlights() + .stream() + .map(HighlightRequest::answerId) + .distinct() + .toList(); + } } diff --git a/backend/src/main/java/reviewme/highlight/service/exception/HighlightDuplicatedException.java b/backend/src/main/java/reviewme/highlight/service/exception/HighlightDuplicatedException.java deleted file mode 100644 index de999bf84..000000000 --- a/backend/src/main/java/reviewme/highlight/service/exception/HighlightDuplicatedException.java +++ /dev/null @@ -1,14 +0,0 @@ -package reviewme.highlight.service.exception; - -import lombok.extern.slf4j.Slf4j; -import reviewme.global.exception.BadRequestException; - -@Slf4j -public class HighlightDuplicatedException extends BadRequestException { - - public HighlightDuplicatedException(long answerId, long lineIndex, long startIndex, long endIndex) { - super("중복된 하이라이트는 생성할 수 없어요."); - log.info("Highlight is duplicated - answerId: {}, lineIndex: {}, startIndex: {}, endIndex: {}", - answerId, lineIndex, startIndex, endIndex); - } -} diff --git a/backend/src/main/java/reviewme/highlight/service/mapper/HighlightMapper.java b/backend/src/main/java/reviewme/highlight/service/mapper/HighlightMapper.java new file mode 100644 index 000000000..edbec9013 --- /dev/null +++ b/backend/src/main/java/reviewme/highlight/service/mapper/HighlightMapper.java @@ -0,0 +1,77 @@ +package reviewme.highlight.service.mapper; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import reviewme.highlight.domain.HighlightedLines; +import reviewme.highlight.domain.HighlightedLine; +import reviewme.highlight.domain.Highlight; +import reviewme.highlight.domain.HighlightRange; +import reviewme.highlight.service.dto.HighlightIndexRangeRequest; +import reviewme.highlight.service.dto.HighlightRequest; +import reviewme.highlight.service.dto.HighlightedLineRequest; +import reviewme.highlight.service.dto.HighlightsRequest; +import reviewme.review.domain.Answer; +import reviewme.review.repository.TextAnswerRepository; + +@Component +@RequiredArgsConstructor +public class HighlightMapper { + + private final TextAnswerRepository textAnswerRepository; + + public List mapToHighlights(HighlightsRequest highlightsRequest) { + Map answerHighlightLines = textAnswerRepository + .findAllById(highlightsRequest.getUniqueAnswerIds()) + .stream() + .collect(Collectors.toMap(Answer::getId, answer -> new HighlightedLines(answer.getContent()))); + addIndexRanges(highlightsRequest, answerHighlightLines); + return mapLinesToHighlights(answerHighlightLines); + } + + private void addIndexRanges(HighlightsRequest highlightsRequest, Map answerHighlightLines) { + for (HighlightRequest highlightRequest : highlightsRequest.highlights()) { + HighlightedLines highlightedLines = answerHighlightLines.get(highlightRequest.answerId()); + addIndexRangesForAnswer(highlightRequest, highlightedLines); + } + } + + private void addIndexRangesForAnswer(HighlightRequest highlightRequest, HighlightedLines highlightedLines) { + for (HighlightedLineRequest lineRequest : highlightRequest.lines()) { + int lineIndex = lineRequest.index(); + for (HighlightIndexRangeRequest rangeRequest : lineRequest.ranges()) { + highlightedLines.addRange(lineIndex, rangeRequest.startIndex(), rangeRequest.endIndex()); + } + } + } + + private List mapLinesToHighlights(Map answerHighlightLines) { + List highlights = new ArrayList<>(); + for (Entry answerHighlightLine : answerHighlightLines.entrySet()) { + createHighlightsForAnswer(answerHighlightLine, highlights); + } + return highlights; + } + + private void createHighlightsForAnswer(Entry answerHighlightLine, + List highlights) { + long answerId = answerHighlightLine.getKey(); + List highlightedLines = answerHighlightLine.getValue().getLines(); + + for (int lineIndex = 0; lineIndex < highlightedLines.size(); lineIndex++) { + createHighlightForLine(highlightedLines, lineIndex, answerId, highlights); + } + } + + private void createHighlightForLine(List highlightedLines, int lineIndex, long answerId, + List highlights) { + for (HighlightRange range : highlightedLines.get(lineIndex).getRanges()) { + Highlight highlight = new Highlight(answerId, lineIndex, range); + highlights.add(highlight); + } + } +} diff --git a/backend/src/main/java/reviewme/highlight/service/validator/HighlightValidator.java b/backend/src/main/java/reviewme/highlight/service/validator/HighlightValidator.java index ad2e323b3..e05f0f9df 100644 --- a/backend/src/main/java/reviewme/highlight/service/validator/HighlightValidator.java +++ b/backend/src/main/java/reviewme/highlight/service/validator/HighlightValidator.java @@ -1,87 +1,41 @@ + package reviewme.highlight.service.validator; import java.util.List; import java.util.Set; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; -import reviewme.highlight.service.dto.HighlightRequest; -import reviewme.highlight.service.dto.HighlightedLineRequest; import reviewme.highlight.service.dto.HighlightsRequest; -import reviewme.highlight.service.exception.InvalidHighlightLineIndexException; import reviewme.highlight.service.exception.SubmittedAnswerAndProvidedAnswerMismatchException; -import reviewme.question.repository.QuestionRepository; -import reviewme.review.domain.TextAnswer; import reviewme.review.repository.AnswerRepository; -import reviewme.review.repository.TextAnswerRepository; -import reviewme.review.service.exception.AnswerNotFoundByIdException; -import reviewme.review.service.exception.SubmittedQuestionAndProvidedQuestionMismatchException; -import reviewme.reviewgroup.repository.ReviewGroupRepository; +import reviewme.reviewgroup.domain.ReviewGroup; @Component @RequiredArgsConstructor public class HighlightValidator { private final AnswerRepository answerRepository; - private final TextAnswerRepository textAnswerRepository; - private final QuestionRepository questionRepository; - private final ReviewGroupRepository reviewGroupRepository; - public void validate(HighlightsRequest request, long reviewGroupId) { - validateReviewGroupContainsQuestion(request, reviewGroupId); - validateReviewGroupContainsAnswer(request, reviewGroupId); + public void validate(HighlightsRequest request, ReviewGroup reviewGroup) { validateQuestionContainsAnswer(request); - validateLineIndex(request); - // TODO: 중복 요청 검증 추가 예정 - } - - private void validateReviewGroupContainsQuestion(HighlightsRequest request, long reviewGroupId) { - long templateId = reviewGroupRepository.findById(reviewGroupId) - .orElseThrow() - .getTemplateId(); - Set providedQuestionIds = questionRepository.findAllQuestionIdByTemplateId(templateId); - long submittedQuestionId = request.questionId(); - - if (!providedQuestionIds.contains(submittedQuestionId)) { - throw new SubmittedQuestionAndProvidedQuestionMismatchException(submittedQuestionId, providedQuestionIds); - } - } - - private void validateReviewGroupContainsAnswer(HighlightsRequest request, long reviewGroupId) { - Set providedAnswerIds = answerRepository.findIdsByReviewGroupId(reviewGroupId); - List submittedAnswerIds = request.highlights() - .stream() - .map(HighlightRequest::answerId) - .toList(); - - if (!providedAnswerIds.containsAll(submittedAnswerIds)) { - throw new SubmittedAnswerAndProvidedAnswerMismatchException(providedAnswerIds, submittedAnswerIds); - } + validateReviewGroupContainsAnswer(request, reviewGroup); } private void validateQuestionContainsAnswer(HighlightsRequest request) { Set providedAnswerIds = answerRepository.findIdsByQuestionId(request.questionId()); - List submittedAnswerIds = request.highlights() - .stream() - .map(HighlightRequest::answerId) - .toList(); + List submittedAnswerIds = request.getUniqueAnswerIds(); if (!providedAnswerIds.containsAll(submittedAnswerIds)) { throw new SubmittedAnswerAndProvidedAnswerMismatchException(providedAnswerIds, submittedAnswerIds); } } - private void validateLineIndex(HighlightsRequest request) { - for (HighlightRequest highlight : request.highlights()) { - TextAnswer textAnswer = textAnswerRepository.findById(highlight.answerId()) - .orElseThrow(() -> new AnswerNotFoundByIdException(highlight.answerId())); - long providedMaxLineIndex = textAnswer.getContent().lines().count() - 1; + private void validateReviewGroupContainsAnswer(HighlightsRequest request, ReviewGroup reviewGroup) { + Set providedAnswerIds = answerRepository.findIdsByReviewGroupId(reviewGroup.getId()); + List submittedAnswerIds = request.getUniqueAnswerIds(); - for (HighlightedLineRequest line : highlight.lines()) { - long submittedLineIndex = line.index(); - if (providedMaxLineIndex < submittedLineIndex) { - throw new InvalidHighlightLineIndexException(submittedLineIndex, providedMaxLineIndex); - } - } + if (!providedAnswerIds.containsAll(submittedAnswerIds)) { + throw new SubmittedAnswerAndProvidedAnswerMismatchException(providedAnswerIds, submittedAnswerIds); } } } diff --git a/backend/src/main/java/reviewme/review/domain/TextAnswers.java b/backend/src/main/java/reviewme/review/domain/TextAnswers.java deleted file mode 100644 index 4ce230eb0..000000000 --- a/backend/src/main/java/reviewme/review/domain/TextAnswers.java +++ /dev/null @@ -1,30 +0,0 @@ -package reviewme.review.domain; - -import java.util.List; -import java.util.Map; -import java.util.function.Function; -import java.util.stream.Collectors; -import lombok.extern.slf4j.Slf4j; -import reviewme.review.domain.exception.MissingTextAnswerForQuestionException; - -@Slf4j -public class TextAnswers { - - private final Map textAnswers; - - public TextAnswers(List textAnswers) { - this.textAnswers = textAnswers.stream() - .collect(Collectors.toMap(TextAnswer::getQuestionId, Function.identity())); - } - - public TextAnswer getAnswerByQuestionId(long questionId) { - if (!textAnswers.containsKey(questionId)) { - throw new MissingTextAnswerForQuestionException(questionId); - } - return textAnswers.get(questionId); - } - - public boolean hasAnswerByQuestionId(long questionId) { - return textAnswers.containsKey(questionId); - } -} diff --git a/backend/src/main/java/reviewme/review/repository/AnswerRepository.java b/backend/src/main/java/reviewme/review/repository/AnswerRepository.java index d296f89fe..ea793623a 100644 --- a/backend/src/main/java/reviewme/review/repository/AnswerRepository.java +++ b/backend/src/main/java/reviewme/review/repository/AnswerRepository.java @@ -28,14 +28,6 @@ public interface AnswerRepository extends JpaRepository { """) Set findIdsByReviewGroupId(long reviewGroupId); - @Query(""" - SELECT a FROM Answer a - JOIN Review r - ON a.reviewId = r.id - WHERE r.reviewGroupId = :reviewGroupId - """) - Set findAllByReviewGroupId(long reviewGroupId); - @Query(""" SELECT a.id FROM Answer a WHERE a.questionId = :questionId diff --git a/backend/src/test/java/reviewme/highlight/domain/HighlightPositionTest.java b/backend/src/test/java/reviewme/highlight/domain/HighlightPositionTest.java deleted file mode 100644 index a858f1d2a..000000000 --- a/backend/src/test/java/reviewme/highlight/domain/HighlightPositionTest.java +++ /dev/null @@ -1,23 +0,0 @@ -package reviewme.highlight.domain; - -import static org.assertj.core.api.Assertions.assertThatCode; - -import org.junit.jupiter.api.Test; -import reviewme.highlight.domain.exception.HighlightStartIndexExceedEndIndexException; -import reviewme.highlight.domain.exception.NegativeHighlightIndexException; - -class HighlightPositionTest { - - @Test - void 하이라이트의_시작_인덱스가_종료_인덱스보다_큰_경우_예외를_발생한다() { - assertThatCode(() -> new HighlightPosition(1, 2, 1)) - .isInstanceOf(HighlightStartIndexExceedEndIndexException.class); - } - - @Test - void 하이라이트의_인덱스들이_0보다_작은_경우_예외를_발생한다() { - assertThatCode(() -> new HighlightPosition(1, -2, -1)) - .isInstanceOf(NegativeHighlightIndexException.class); - - } -} diff --git a/backend/src/test/java/reviewme/highlight/domain/HighlightedLineTest.java b/backend/src/test/java/reviewme/highlight/domain/HighlightedLineTest.java new file mode 100644 index 000000000..200300bbf --- /dev/null +++ b/backend/src/test/java/reviewme/highlight/domain/HighlightedLineTest.java @@ -0,0 +1,36 @@ +package reviewme.highlight.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode; + +import java.util.Set; +import org.junit.jupiter.api.Test; +import reviewme.highlight.domain.exception.HighlightIndexExceedLineLengthException; + +class HighlightedLineTest { + + @Test + void 하이라이트_대상_라인의_글자수보다_큰_시작_종료_인덱스_범위를_추가하려고_하면_예외를_발생한다() { + // given + String content = "12345"; + HighlightedLine highlightedLine = new HighlightedLine(content); + + // when && then + assertThatCode(() -> highlightedLine.addRange(content.length() - 1, content.length())) + .isInstanceOf(HighlightIndexExceedLineLengthException.class); + } + + @Test + void 하이라이트_할_라인의_시작_종료_인덱스_범위를_추가한다() { + // given + HighlightedLine highlightedLine = new HighlightedLine("12345"); + + // when + highlightedLine.addRange(2, 4); + highlightedLine.addRange(0, 1); + + // then + Set ranges = highlightedLine.getRanges(); + assertThat(ranges).containsExactly(new HighlightRange(2, 4), new HighlightRange(0, 1)); + } +} diff --git a/backend/src/test/java/reviewme/highlight/domain/HighlightedLinesTest.java b/backend/src/test/java/reviewme/highlight/domain/HighlightedLinesTest.java new file mode 100644 index 000000000..53d81c209 --- /dev/null +++ b/backend/src/test/java/reviewme/highlight/domain/HighlightedLinesTest.java @@ -0,0 +1,87 @@ +package reviewme.highlight.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import reviewme.highlight.domain.exception.InvalidHighlightLineIndexException; +import reviewme.highlight.domain.exception.NegativeHighlightLineIndexException; +import reviewme.review.domain.Review; +import reviewme.review.domain.TextAnswer; +import reviewme.review.repository.ReviewRepository; + +@DataJpaTest +class HighlightedLinesTest { + + @Autowired + private ReviewRepository reviewRepository; + + @Test + void 답변_내용으로_하이라이트에_사용될_라인을_생성한다() { + // given + TextAnswer answer = new TextAnswer(1L, "123\n456\n789"); + reviewRepository.save(new Review(1L, 1L, List.of(answer))); + + // when + HighlightedLines highlightedLines = new HighlightedLines(answer.getContent()); + + // then + assertThat(highlightedLines.getLines()).containsExactly( + new HighlightedLine("123"), + new HighlightedLine("456"), + new HighlightedLine("789") + ); + } + + @Test + void 특정_라인에_하이라이트_시작_종료_범위를_추가한다() { + // given + TextAnswer answer = new TextAnswer(1L, "123\n456\n78910"); + reviewRepository.save(new Review(1L, 1L, List.of(answer))); + HighlightedLines highlightedLines = new HighlightedLines(answer.getContent()); + + // when + highlightedLines.addRange(0, 1, 1); + highlightedLines.addRange(2, 0, 1); + highlightedLines.addRange(2, 3, 4); + + // then + List lines = highlightedLines.getLines(); + assertAll( + () -> assertThat(lines.get(0).getRanges()) + .containsExactly(new HighlightRange(1, 1)), + () -> assertThat(lines.get(2).getRanges()) + .containsExactly(new HighlightRange(0, 1), new HighlightRange(3, 4)) + ); + } + + @Test + void 하이라이트에_추가할_라인의_인덱스가_0보다_작을_경우_예외를_발생한다() { + // given + HighlightedLines highlightedLines = new HighlightedLines("123\n456"); + int negativeLineIndex = -1; + + // when && then + assertThatCode(() -> highlightedLines.addRange(negativeLineIndex, 0, 1)) + .isInstanceOf(NegativeHighlightLineIndexException.class); + } + + @Test + void 하이라이트에_추가할_라인의_인덱스가_대상_답변의_라인_수를_넘으면_예외를_발생한다() { + // given + String content = "123\n456"; + TextAnswer answer = new TextAnswer(1L, content); + reviewRepository.save(new Review(1L, 1L, List.of(answer))); + HighlightedLines highlightedLines = new HighlightedLines(answer.getContent()); + int invalidLineIndex = (int) content.lines().count(); + System.out.println(invalidLineIndex); + + // when && then + assertThatCode(() -> highlightedLines.addRange(invalidLineIndex, 0, 1)) + .isInstanceOf(InvalidHighlightLineIndexException.class); + } +} diff --git a/backend/src/test/java/reviewme/highlight/entity/HighlightRangeTest.java b/backend/src/test/java/reviewme/highlight/entity/HighlightRangeTest.java new file mode 100644 index 000000000..84f164490 --- /dev/null +++ b/backend/src/test/java/reviewme/highlight/entity/HighlightRangeTest.java @@ -0,0 +1,22 @@ +package reviewme.highlight.entity; + +import static org.assertj.core.api.Assertions.assertThatCode; + +import org.junit.jupiter.api.Test; +import reviewme.highlight.domain.HighlightRange; +import reviewme.highlight.domain.exception.InvalidHighlightIndexRangeException; + +class HighlightRangeTest { + + @Test + void 하이라이트의_시작_인덱스가_종료_인덱스보다_큰_경우_예외를_발생한다() { + assertThatCode(() -> new HighlightRange(2, 1)) + .isInstanceOf(InvalidHighlightIndexRangeException.class); + } + + @Test + void 하이라이트의_인덱스들이_0보다_작은_경우_예외를_발생한다() { + assertThatCode(() -> new HighlightRange(-2, -1)) + .isInstanceOf(InvalidHighlightIndexRangeException.class); + } +} diff --git a/backend/src/test/java/reviewme/highlight/service/HighlightServiceTest.java b/backend/src/test/java/reviewme/highlight/service/HighlightServiceTest.java index a31403588..32eed36b8 100644 --- a/backend/src/test/java/reviewme/highlight/service/HighlightServiceTest.java +++ b/backend/src/test/java/reviewme/highlight/service/HighlightServiceTest.java @@ -11,7 +11,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import reviewme.highlight.domain.Highlight; -import reviewme.highlight.domain.HighlightPosition; +import reviewme.highlight.domain.HighlightRange; import reviewme.highlight.repository.HighlightRepository; import reviewme.highlight.service.dto.HighlightIndexRangeRequest; import reviewme.highlight.service.dto.HighlightRequest; @@ -59,30 +59,22 @@ class HighlightServiceTest { long templateId = templateRepository.save(템플릿(List.of(sectionId))).getId(); String reviewRequestCode = "reviewRequestCode"; ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹(reviewRequestCode, "groupAccessCode")); - Highlight highlight1 = highlightRepository.save(new Highlight(1, 1, 1, 1)); - Highlight highlight2 = highlightRepository.save(new Highlight(2, 1, 1, 1)); TextAnswer textAnswer1 = new TextAnswer(questionId, "text answer1"); TextAnswer textAnswer2 = new TextAnswer(questionId, "text answer2"); - Review review = reviewRepository.save( - new Review(templateId, reviewGroup.getId(), List.of(textAnswer1, textAnswer2))); + Review review = reviewRepository.save(new Review(templateId, reviewGroup.getId(), List.of(textAnswer1, textAnswer2))); + Highlight highlight = highlightRepository.save(new Highlight(textAnswer1.getId(), 1, new HighlightRange(1, 1))); HighlightIndexRangeRequest indexRangeRequest = new HighlightIndexRangeRequest(1, 1); HighlightedLineRequest lineRequest = new HighlightedLineRequest(0, List.of(indexRangeRequest)); - HighlightRequest highlightRequest1 = new HighlightRequest(textAnswer1.getId(), List.of(lineRequest)); - HighlightRequest highlightRequest2 = new HighlightRequest(textAnswer2.getId(), List.of(lineRequest)); - HighlightsRequest highlightsRequest = new HighlightsRequest( - questionId, List.of(highlightRequest1, highlightRequest2) - ); + HighlightRequest highlightRequest1 = new HighlightRequest(textAnswer2.getId(), List.of(lineRequest)); + HighlightsRequest highlightsRequest = new HighlightsRequest(questionId, List.of(highlightRequest1)); // when - highlightService.highlight(highlightsRequest, reviewGroup); + highlightService.editHighlight(highlightsRequest, reviewGroup); // then - assertAll( - () -> assertThat(highlightRepository.existsById(highlight1.getId())).isFalse(), - () -> assertThat(highlightRepository.existsById(highlight2.getId())).isFalse() - ); + assertAll(() -> assertThat(highlightRepository.existsById(highlight.getId())).isFalse()); } @Test @@ -93,34 +85,50 @@ class HighlightServiceTest { long templateId = templateRepository.save(템플릿(List.of(sectionId))).getId(); String reviewRequestCode = "reviewRequestCode"; ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹(reviewRequestCode, "groupAccessCode")); - highlightRepository.save(new Highlight(1, 1, 1, 1)); - TextAnswer textAnswer1 = new TextAnswer(questionId, "text answer1"); - TextAnswer textAnswer2 = new TextAnswer(questionId, "text answer2"); - Review review = reviewRepository.save(new Review(templateId, reviewGroup.getId(), List.of(textAnswer1, textAnswer2))); + + TextAnswer textAnswer = new TextAnswer(questionId, "text answer1"); + Review review = reviewRepository.save(new Review(templateId, reviewGroup.getId(), List.of(textAnswer))); + highlightRepository.save(new Highlight(1, 1, new HighlightRange(1, 1))); int startIndex = 2; int endIndex = 2; - int lineIndex = 0; HighlightIndexRangeRequest indexRangeRequest = new HighlightIndexRangeRequest(startIndex, endIndex); - HighlightedLineRequest lineRequest1 = new HighlightedLineRequest(lineIndex, List.of(indexRangeRequest)); - HighlightedLineRequest lineRequest2 = new HighlightedLineRequest(lineIndex, List.of(indexRangeRequest)); - HighlightRequest highlightRequest1 = new HighlightRequest(textAnswer1.getId(), List.of(lineRequest1)); - HighlightRequest highlightRequest2 = new HighlightRequest(textAnswer2.getId(), List.of(lineRequest2)); - HighlightsRequest highlightsRequest = new HighlightsRequest(questionId, - List.of(highlightRequest1, highlightRequest2)); + HighlightedLineRequest lineRequest = new HighlightedLineRequest(0, List.of(indexRangeRequest)); + HighlightRequest highlightRequest = new HighlightRequest(textAnswer.getId(), List.of(lineRequest)); + HighlightsRequest highlightsRequest = new HighlightsRequest(questionId, List.of(highlightRequest)); // when - highlightService.highlight(highlightsRequest, reviewGroup); + highlightService.editHighlight(highlightsRequest, reviewGroup); // then List highlights = highlightRepository.findAll(); - HighlightPosition position = new HighlightPosition(lineIndex, startIndex, endIndex); assertAll( - () -> assertThat(highlights.get(0).getAnswerId()).isEqualTo(textAnswer1.getId()), - () -> assertThat(highlights.get(1).getAnswerId()).isEqualTo(textAnswer2.getId()), - () -> assertThat(highlights.get(0).getHighlightPosition()).isEqualTo(position), - () -> assertThat(highlights.get(0).getHighlightPosition()).isEqualTo(position) + () -> assertThat(highlights.get(0).getAnswerId()).isEqualTo(textAnswer.getId()), + () -> assertThat(highlights.get(0).getHighlightRange()).isEqualTo( + new HighlightRange(startIndex, endIndex)) ); } + + @Test + void 하이라이트_할_내용이_없는_요청이_오면_기존에_있던_내용을_삭제하고_아무것도_저장하지_않는다() { + // given + long questionId = questionRepository.save(서술형_필수_질문()).getId(); + long sectionId = sectionRepository.save(항상_보이는_섹션(List.of(questionId))).getId(); + long templateId = templateRepository.save(템플릿(List.of(sectionId))).getId(); + String reviewRequestCode = "reviewRequestCode"; + ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹(reviewRequestCode, "groupAccessCode")); + + TextAnswer textAnswer = new TextAnswer(questionId, "text answer1"); + Review review = reviewRepository.save(new Review(templateId, reviewGroup.getId(), List.of(textAnswer))); + Highlight highlight = highlightRepository.save(new Highlight(textAnswer.getId(), 1, new HighlightRange(1, 1))); + + HighlightsRequest highlightsRequest = new HighlightsRequest(questionId, List.of()); + + // when + highlightService.editHighlight(highlightsRequest, reviewGroup); + + // then + assertAll(() -> assertThat(highlightRepository.existsById(highlight.getId())).isFalse()); + } } diff --git a/backend/src/test/java/reviewme/highlight/service/mapper/HighlightMapperTest.java b/backend/src/test/java/reviewme/highlight/service/mapper/HighlightMapperTest.java new file mode 100644 index 000000000..14a6639f9 --- /dev/null +++ b/backend/src/test/java/reviewme/highlight/service/mapper/HighlightMapperTest.java @@ -0,0 +1,104 @@ +package reviewme.highlight.service.mapper; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static reviewme.fixture.QuestionFixture.서술형_필수_질문; +import static reviewme.fixture.ReviewGroupFixture.리뷰_그룹; +import static reviewme.fixture.SectionFixture.항상_보이는_섹션; +import static reviewme.fixture.TemplateFixture.템플릿; + +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import reviewme.highlight.domain.Highlight; +import reviewme.highlight.domain.HighlightRange; +import reviewme.highlight.repository.HighlightRepository; +import reviewme.highlight.service.dto.HighlightIndexRangeRequest; +import reviewme.highlight.service.dto.HighlightRequest; +import reviewme.highlight.service.dto.HighlightedLineRequest; +import reviewme.highlight.service.dto.HighlightsRequest; +import reviewme.question.repository.QuestionRepository; +import reviewme.review.domain.Review; +import reviewme.review.domain.TextAnswer; +import reviewme.review.repository.ReviewRepository; +import reviewme.reviewgroup.repository.ReviewGroupRepository; +import reviewme.support.ServiceTest; +import reviewme.template.repository.SectionRepository; +import reviewme.template.repository.TemplateRepository; + +@ServiceTest +class HighlightMapperTest { + + @Autowired + private HighlightMapper highlightMapper; + + @Autowired + private HighlightRepository highlightRepository; + + @Autowired + private ReviewGroupRepository reviewGroupRepository; + + @Autowired + private ReviewRepository reviewRepository; + + @Autowired + private QuestionRepository questionRepository; + + @Autowired + private SectionRepository sectionRepository; + + @Autowired + private TemplateRepository templateRepository; + + @Test + void 하이라이트_요청과_기존_서술형_답변으로_하이라이트를_매핑한다() { + // given + long questionId = questionRepository.save(서술형_필수_질문()).getId(); + long sectionId = sectionRepository.save(항상_보이는_섹션(List.of(questionId))).getId(); + long templateId = templateRepository.save(템플릿(List.of(sectionId))).getId(); + String reviewRequestCode = "reviewRequestCode"; + long reviewGroupId = reviewGroupRepository.save(리뷰_그룹(reviewRequestCode, "groupAccessCode")) + .getId(); + + TextAnswer textAnswer1 = new TextAnswer(questionId, "text answer1"); + TextAnswer textAnswer2 = new TextAnswer(questionId, "text answer2"); + Review review = reviewRepository.save(new Review(templateId, reviewGroupId, List.of(textAnswer1, textAnswer2))); + + highlightRepository.save(new Highlight(1, 1, new HighlightRange(1, 1))); + + int startIndex = 2; + int endIndex = 2; + int lineIndex = 0; + HighlightIndexRangeRequest rangeRequest = new HighlightIndexRangeRequest(startIndex, endIndex); + HighlightedLineRequest lineRequest1 = new HighlightedLineRequest(lineIndex, List.of(rangeRequest)); + HighlightedLineRequest lineRequest2 = new HighlightedLineRequest(lineIndex, List.of(rangeRequest)); + HighlightRequest highlightRequest1 = new HighlightRequest(textAnswer1.getId(), List.of(lineRequest1)); + HighlightRequest highlightRequest2 = new HighlightRequest(textAnswer2.getId(), List.of(lineRequest2)); + HighlightsRequest highlightsRequest = new HighlightsRequest(questionId, + List.of(highlightRequest1, highlightRequest2)); + + // when + List highlights = highlightMapper.mapToHighlights(highlightsRequest); + + // then + HighlightRange range = new HighlightRange(startIndex, endIndex); + assertAll( + () -> assertThat(highlights.get(0).getAnswerId()).isEqualTo(textAnswer1.getId()), + () -> assertThat(highlights.get(1).getAnswerId()).isEqualTo(textAnswer2.getId()), + () -> assertThat(highlights.get(0).getHighlightRange()).isEqualTo(range), + () -> assertThat(highlights.get(1).getHighlightRange()).isEqualTo(range) + ); + } + + @Test + void 하이라이트_할_내용이_없는_요청이_오면_매핑_결과_빈_리스트를_반환한다() { + // given + HighlightsRequest highlightsRequest = new HighlightsRequest(1L, List.of()); + + // when + List highlights = highlightMapper.mapToHighlights(highlightsRequest); + + // then + assertThat(highlights).isEmpty(); + } +} diff --git a/backend/src/test/java/reviewme/highlight/service/validator/HighlightValidatorTest.java b/backend/src/test/java/reviewme/highlight/service/validator/HighlightValidatorTest.java index 21f1f8638..84bf793d2 100644 --- a/backend/src/test/java/reviewme/highlight/service/validator/HighlightValidatorTest.java +++ b/backend/src/test/java/reviewme/highlight/service/validator/HighlightValidatorTest.java @@ -1,25 +1,21 @@ package reviewme.highlight.service.validator; import static org.assertj.core.api.Assertions.assertThatCode; +import static reviewme.fixture.QuestionFixture.서술형_필수_질문; +import static reviewme.fixture.ReviewGroupFixture.리뷰_그룹; import static reviewme.fixture.SectionFixture.항상_보이는_섹션; import static reviewme.fixture.TemplateFixture.템플릿; import java.util.List; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import reviewme.fixture.QuestionFixture; -import reviewme.fixture.ReviewGroupFixture; import reviewme.highlight.service.dto.HighlightRequest; -import reviewme.highlight.service.dto.HighlightedLineRequest; import reviewme.highlight.service.dto.HighlightsRequest; -import reviewme.highlight.service.exception.InvalidHighlightLineIndexException; import reviewme.highlight.service.exception.SubmittedAnswerAndProvidedAnswerMismatchException; -import reviewme.question.domain.Question; import reviewme.question.repository.QuestionRepository; import reviewme.review.domain.Review; import reviewme.review.domain.TextAnswer; import reviewme.review.repository.ReviewRepository; -import reviewme.review.service.exception.SubmittedQuestionAndProvidedQuestionMismatchException; import reviewme.reviewgroup.domain.ReviewGroup; import reviewme.reviewgroup.repository.ReviewGroupRepository; import reviewme.support.ServiceTest; @@ -50,26 +46,33 @@ class HighlightValidatorTest { private TemplateRepository templateRepository; @Test - void 하이라이트의_질문_id가_리뷰_그룹의_템플릿에_속한_질문이_아니면_예외를_발생한다() { + void 하이라이트의_답변_id가_하이라이트의_질문_id에_해당하는_답변이_아니면_예외를_발생한다() { // given - ReviewGroup reviewGroup = reviewGroupRepository.save(ReviewGroupFixture.리뷰_그룹()); - Question question = questionRepository.save(QuestionFixture.서술형_필수_질문()); - HighlightsRequest highlightsRequest = new HighlightsRequest(question.getId(), List.of()); + long questionId1 = questionRepository.save(서술형_필수_질문()).getId(); + long questionId2 = questionRepository.save(서술형_필수_질문()).getId(); + Section section = sectionRepository.save(항상_보이는_섹션(List.of(questionId1, questionId2))); + Template template = templateRepository.save(템플릿(List.of(section.getId()))); + + ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹()); + TextAnswer textAnswer_Q1 = new TextAnswer(questionId1, "text answer 1"); + + HighlightRequest highlightRequest = new HighlightRequest(textAnswer_Q1.getId(), List.of()); + HighlightsRequest highlightsRequest = new HighlightsRequest(questionId2, List.of(highlightRequest)); // when && then - assertThatCode(() -> highlightValidator.validate(highlightsRequest, reviewGroup.getId())) - .isInstanceOf(SubmittedQuestionAndProvidedQuestionMismatchException.class); + assertThatCode(() -> highlightValidator.validate(highlightsRequest, reviewGroup)) + .isInstanceOf(SubmittedAnswerAndProvidedAnswerMismatchException.class); } @Test void 하이라이트의_답변_id가_리뷰_그룹에_달린_답변이_아니면_예외를_발생한다() { // given - long questionId = questionRepository.save(QuestionFixture.서술형_필수_질문()).getId(); + long questionId = questionRepository.save(서술형_필수_질문()).getId(); Section section = sectionRepository.save(항상_보이는_섹션(List.of(questionId))); Template template = templateRepository.save(템플릿(List.of(section.getId()))); - ReviewGroup reviewGroup1 = reviewGroupRepository.save(ReviewGroupFixture.리뷰_그룹()); - ReviewGroup reviewGroup2 = reviewGroupRepository.save(ReviewGroupFixture.리뷰_그룹()); + ReviewGroup reviewGroup1 = reviewGroupRepository.save(리뷰_그룹()); + ReviewGroup reviewGroup2 = reviewGroupRepository.save(리뷰_그룹()); TextAnswer textAnswer1 = new TextAnswer(questionId, "text answer1"); TextAnswer textAnswer2 = new TextAnswer(questionId, "text answer2"); reviewRepository.saveAll(List.of( @@ -81,47 +84,26 @@ class HighlightValidatorTest { HighlightsRequest highlightsRequest = new HighlightsRequest(1L, List.of(highlightRequest)); // when && then - assertThatCode(() -> highlightValidator.validate(highlightsRequest, reviewGroup1.getId())) + assertThatCode(() -> highlightValidator.validate(highlightsRequest, reviewGroup1)) .isInstanceOf(SubmittedAnswerAndProvidedAnswerMismatchException.class); } @Test - void 하이라이트의_답변_id가_하이라이트의_질문_id에_해당하는_답변이_아니면_예외를_발생한다() { + void 하이라이트의_질문_id가_리뷰_그룹의_템플릿에_속한_질문이_아니면_예외를_발생한다() { // given - long questionId1 = questionRepository.save(QuestionFixture.서술형_필수_질문()).getId(); - long questionId2 = questionRepository.save(QuestionFixture.서술형_필수_질문()).getId(); - Section section = sectionRepository.save(항상_보이는_섹션(List.of(questionId1, questionId2))); + long questionId1 = questionRepository.save(서술형_필수_질문()).getId(); + long questionId2 = questionRepository.save(서술형_필수_질문()).getId(); + Section section = sectionRepository.save(항상_보이는_섹션(List.of(questionId1))); Template template = templateRepository.save(템플릿(List.of(section.getId()))); - long reviewGroupId = reviewGroupRepository.save(ReviewGroupFixture.리뷰_그룹()).getId(); + ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹()); TextAnswer textAnswer_Q1 = new TextAnswer(questionId1, "text answer 1"); HighlightRequest highlightRequest = new HighlightRequest(textAnswer_Q1.getId(), List.of()); HighlightsRequest highlightsRequest = new HighlightsRequest(questionId2, List.of(highlightRequest)); // when && then - assertThatCode(() -> highlightValidator.validate(highlightsRequest, reviewGroupId)) + assertThatCode(() -> highlightValidator.validate(highlightsRequest, reviewGroup)) .isInstanceOf(SubmittedAnswerAndProvidedAnswerMismatchException.class); } - - @Test - void 답변의_줄_수보다_하이라이트의_줄_번호가_더_크면_예외를_발생한다() { - // given - long questionId = questionRepository.save(QuestionFixture.서술형_필수_질문()).getId(); - long sectionId = sectionRepository.save(항상_보이는_섹션(List.of(questionId))).getId(); - long templateId = templateRepository.save(템플릿(List.of(sectionId))).getId(); - - TextAnswer textAnswer = new TextAnswer(questionId, "line 1\n line 2"); - long reviewGroupId = reviewGroupRepository.save(ReviewGroupFixture.리뷰_그룹()).getId(); - Review review = reviewRepository.save(new Review(templateId, reviewGroupId, List.of(textAnswer))); - - long answerLineCount = textAnswer.getContent().lines().count(); - HighlightedLineRequest highlightedLineRequest = new HighlightedLineRequest((int) answerLineCount, List.of()); - HighlightRequest highlightRequest = new HighlightRequest(textAnswer.getId(), List.of(highlightedLineRequest)); - HighlightsRequest highlightsRequest = new HighlightsRequest(questionId, List.of(highlightRequest)); - - // when & then - assertThatCode(() -> highlightValidator.validate(highlightsRequest, reviewGroupId)) - .isInstanceOf(InvalidHighlightLineIndexException.class); - } } diff --git a/backend/src/test/java/reviewme/review/domain/TextAnswersTest.java b/backend/src/test/java/reviewme/review/domain/TextAnswersTest.java deleted file mode 100644 index 82eeb7e0b..000000000 --- a/backend/src/test/java/reviewme/review/domain/TextAnswersTest.java +++ /dev/null @@ -1,50 +0,0 @@ -package reviewme.review.domain; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.junit.jupiter.api.Assertions.assertAll; - -import java.util.List; -import org.junit.jupiter.api.Test; -import reviewme.review.domain.exception.MissingTextAnswerForQuestionException; - -class TextAnswersTest { - - @Test - void 질문에_해당하는_답변이_없으면_예외를_발생한다() { - // given - TextAnswers textAnswers = new TextAnswers(List.of(new TextAnswer(1, "답".repeat(20)))); - - // when, then - assertThatThrownBy(() -> textAnswers.getAnswerByQuestionId(2)) - .isInstanceOf(MissingTextAnswerForQuestionException.class); - } - - @Test - void 질문_ID로_서술형_답변을_반환한다() { - // given - TextAnswers textAnswers = new TextAnswers(List.of(new TextAnswer(1, "답".repeat(20)))); - - // when - TextAnswer actual = textAnswers.getAnswerByQuestionId(1); - - // then - assertThat(actual.getContent()).isEqualTo("답".repeat(20)); - } - - @Test - void 질문_ID에_해당하는_답변이_있는지_확인한다() { - // given - TextAnswers textAnswers = new TextAnswers(List.of(new TextAnswer(1, "답변"))); - - // when - boolean actual1 = textAnswers.hasAnswerByQuestionId(1); - boolean actual2 = textAnswers.hasAnswerByQuestionId(2); - - // then - assertAll( - () -> assertThat(actual1).isTrue(), - () -> assertThat(actual2).isFalse() - ); - } -} From be8f2f18ed26ebbfbc1c5362503e28d97ef5b980 Mon Sep 17 00:00:00 2001 From: Yeongseo Na Date: Wed, 16 Oct 2024 10:59:59 +0900 Subject: [PATCH 23/49] =?UTF-8?q?[BE]=20fix:=20=EC=A7=88=EB=AC=B8=EC=9D=84?= =?UTF-8?q?=20position=20=EC=88=9C=EC=84=9C=EB=8C=80=EB=A1=9C=20=EB=82=B4?= =?UTF-8?q?=EB=A0=A4=EC=A3=BC=EB=8F=84=EB=A1=9D=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(#834)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 질문을 순서대로 내려주는 로직 수정 AS-IS: Question을 position으로 정렬한 값을 받아서 Map> 으로 만든 다음, mapper 에 전달했다. 이때, map 은 순서를 보장하지 않으므로 mapper 에서 position 순서대로 내려지지 않았다. TO-BE: 쿼리에서 정렬하지 않고, mapper에서 정렬하도록 바꿔주었다. * Revert "fix: 질문을 순서대로 내려주는 로직 수정" This reverts commit 4ff8cc37e079b5aaa2d038ffa884d94fbefecbdd. * fix: 질문을 순서대로 내려주는 로직 수정 AS-IS: Question을 position으로 정렬한 값을 받아서 Map> 으로 만든 다음, mapper 에 전달했다. 이때, map 은 순서를 보장하지 않으므로 mapper 에서 position 순서대로 내려지지 않았다. TO-BE: LinkedHashMap 을 사용해서 순서를 보장한다. * refactor: 가독성 개선 * refactor: 질문 순서 보장 로직 수정 - 반환값도 LinkedHashMap 이 되도록 * fix: 컴파일 에러 해결 --- .../service/ReviewGatheredLookupService.java | 23 +++++++-------- .../ReviewGatheredLookupServiceTest.java | 29 +++++++++++++++++-- 2 files changed, 36 insertions(+), 16 deletions(-) diff --git a/backend/src/main/java/reviewme/review/service/ReviewGatheredLookupService.java b/backend/src/main/java/reviewme/review/service/ReviewGatheredLookupService.java index e8de2aee8..a03f2aca2 100644 --- a/backend/src/main/java/reviewme/review/service/ReviewGatheredLookupService.java +++ b/backend/src/main/java/reviewme/review/service/ReviewGatheredLookupService.java @@ -1,8 +1,8 @@ package reviewme.review.service; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.function.Function; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -44,21 +44,18 @@ private Section getSectionOrThrow(long sectionId, ReviewGroup reviewGroup) { } private Map> getQuestionAnswers(Section section, ReviewGroup reviewGroup) { - Map questionIdQuestion = questionRepository - .findAllBySectionIdOrderByPosition(section.getId()) - .stream() - .collect(Collectors.toMap(Question::getId, Function.identity())); + List questions = questionRepository.findAllBySectionIdOrderByPosition(section.getId()); + Map questionIdQuestion = new LinkedHashMap<>(); + questions.forEach(question -> questionIdQuestion.put(question.getId(), question)); - Map> questionIdAnswers = answerRepository - .findReceivedAnswersByQuestionIds(reviewGroup.getId(), questionIdQuestion.keySet(), - ANSWER_RESPONSE_LIMIT) + Map> questionIdAnswers = answerRepository.findReceivedAnswersByQuestionIds( + reviewGroup.getId(), questionIdQuestion.keySet(), ANSWER_RESPONSE_LIMIT) .stream() .collect(Collectors.groupingBy(Answer::getQuestionId)); - return questionIdQuestion.values().stream() - .collect(Collectors.toMap( - Function.identity(), - question -> questionIdAnswers.getOrDefault(question.getId(), List.of()) - )); + Map> questionAnswers = new LinkedHashMap<>(); + questionIdQuestion.values().forEach( + question -> questionAnswers.put(question, questionIdAnswers.getOrDefault(question.getId(), List.of()))); + return questionAnswers; } } diff --git a/backend/src/test/java/reviewme/review/service/ReviewGatheredLookupServiceTest.java b/backend/src/test/java/reviewme/review/service/ReviewGatheredLookupServiceTest.java index 409ac1210..141992950 100644 --- a/backend/src/test/java/reviewme/review/service/ReviewGatheredLookupServiceTest.java +++ b/backend/src/test/java/reviewme/review/service/ReviewGatheredLookupServiceTest.java @@ -22,6 +22,7 @@ import reviewme.question.domain.OptionItem; import reviewme.question.domain.OptionType; import reviewme.question.domain.Question; +import reviewme.question.domain.QuestionType; import reviewme.question.repository.OptionGroupRepository; import reviewme.question.repository.OptionItemRepository; import reviewme.question.repository.QuestionRepository; @@ -69,13 +70,11 @@ class ReviewGatheredLookupServiceTest { @Autowired private ReviewGatheredLookupService reviewLookupService; - private String reviewRequestCode; private ReviewGroup reviewGroup; @BeforeEach void saveReviewGroup() { - reviewRequestCode = "1111"; - reviewGroup = reviewGroupRepository.save(리뷰_그룹(reviewRequestCode, "2222")); + reviewGroup = reviewGroupRepository.save(리뷰_그룹("1111", "2222")); } @Nested @@ -401,4 +400,28 @@ class GatherOptionAnswerByQuestionTest { // then assertThat(actual.reviews()).hasSize(1); } + + @Test + void 질문을_position순서대로_반환한다() { + // given + Question question1 = questionRepository.save(new Question(false, QuestionType.TEXT, "질문1", null, 3)); + Question question2 = questionRepository.save(new Question(false, QuestionType.TEXT, "질문2", null, 4)); + Question question3 = questionRepository.save(new Question(false, QuestionType.TEXT, "질문3", null, 1)); + Question question4 = questionRepository.save(new Question(false, QuestionType.TEXT, "질문4", null, 2)); + + Section section1 = sectionRepository.save(항상_보이는_섹션( + List.of(question1.getId(), question2.getId(), question3.getId(), question4.getId()))); + Template template = templateRepository.save(템플릿(List.of(section1.getId()))); + + // when + ReviewsGatheredBySectionResponse actual = reviewLookupService.getReceivedReviewsBySectionId( + reviewGroup, section1.getId()); + + // then + assertThat(actual.reviews()) + .extracting(ReviewsGatheredByQuestionResponse::question) + .extracting(SimpleQuestionResponse::name) + .containsExactly(question3.getContent(), question4.getContent(), + question1.getContent(), question2.getContent()); + } } From ff53dbe5ec90eb5c2ff0901e8c5151b717557812 Mon Sep 17 00:00:00 2001 From: sooyeon Date: Wed, 16 Oct 2024 17:28:10 +0900 Subject: [PATCH 24/49] =?UTF-8?q?[FE]=20fix:=20=EC=B0=A8=ED=8A=B8=EC=97=90?= =?UTF-8?q?=EC=84=9C=200%=20=EB=B9=84=EC=9C=A8=20=EC=A0=9C=EC=99=B8=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B0=9D=EA=B4=80=EC=8B=9D=20=EC=A7=88=EB=AC=B8?= =?UTF-8?q?=EC=9D=84=20=ED=88=AC=ED=91=9C=EC=88=98=20=EA=B8=B0=EC=A4=80?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=82=B4=EB=A6=BC=EC=B0=A8=EC=88=9C=20?= =?UTF-8?q?=EC=A0=95=EB=A0=AC=20(#837)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: preview가 짧을 때 스타일이 깨지던 현상 수정 * chore: 목 데이터 count 값 수정 * fix: 0%인 객관식 질문을 차트에서 제외 * feat: 객관식 질문을 투표수에 따라 내림차순 정렬 * refactor: 차트 비율을 소수점 첫째 자리까지 반올림 * refactor: 차트 관련 상수 정의 * feat: 여러 줄(3줄)에 대한 ellipsis 설정 * chore: 리뷰 목록의 일부 모킹 데이터 수정 - 가짜 말줄임표 제거 * chore: 불필요한 속성 제거 * refactor: 차트 색상 객체의 키 값을 카멜케이스로 수정 --------- Co-authored-by: ImxYJL --- frontend/src/components/ReviewCard/index.tsx | 2 +- frontend/src/components/ReviewCard/styles.ts | 21 +++++-- .../src/mocks/mockData/reviewCollection.ts | 10 ++-- .../src/mocks/mockData/reviewListMockData.ts | 54 ++++++++++++++--- .../components/DoughnutChart/index.tsx | 58 +++++++++++-------- .../src/pages/ReviewCollectionPage/index.tsx | 4 ++ .../utils/generateGradientColors.ts | 20 ++++++- 7 files changed, 124 insertions(+), 45 deletions(-) diff --git a/frontend/src/components/ReviewCard/index.tsx b/frontend/src/components/ReviewCard/index.tsx index bcb71a803..2aba2880d 100644 --- a/frontend/src/components/ReviewCard/index.tsx +++ b/frontend/src/components/ReviewCard/index.tsx @@ -15,7 +15,7 @@ const ReviewCard = ({ createdAt, contentPreview, categories, handleClick }: Revi - {contentPreview} + {contentPreview} {categories.map((category) => ( diff --git a/frontend/src/components/ReviewCard/styles.ts b/frontend/src/components/ReviewCard/styles.ts index cabca6485..b250ad6bc 100644 --- a/frontend/src/components/ReviewCard/styles.ts +++ b/frontend/src/components/ReviewCard/styles.ts @@ -18,7 +18,7 @@ export const Layout = styled.div` `; export const LeftLineBorder = styled.div` - width: 5rem; + width: 2.5rem; background-color: ${({ theme }) => theme.colors.lightGray}; border-radius: 1rem 0 0 1rem; `; @@ -32,7 +32,6 @@ export const Date = styled.p` height: fit-content; padding: 0 1rem; font-size: 1.3rem; - background-color: ${({ theme }) => theme.colors.lightGray}; `; export const Visibility = styled.div` @@ -53,19 +52,31 @@ export const Main = styled.div` flex-direction: column; gap: 2rem; + width: 100%; padding: 2rem 3rem; font-size: 1.6rem; +`; - span { - overflow-wrap: break-word; - } +export const ContentPreview = styled.p` + overflow: hidden; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + + height: 6rem; + padding-right: 2rem; + + line-height: 2rem; + text-overflow: ellipsis; + overflow-wrap: break-word; `; export const Footer = styled.div` display: flex; align-items: center; justify-content: space-between; + width: 100%; ${media.small} { flex-direction: column; diff --git a/frontend/src/mocks/mockData/reviewCollection.ts b/frontend/src/mocks/mockData/reviewCollection.ts index d7ee7b730..27e0db910 100644 --- a/frontend/src/mocks/mockData/reviewCollection.ts +++ b/frontend/src/mocks/mockData/reviewCollection.ts @@ -29,11 +29,11 @@ export const GROUPED_REVIEWS_MOCK_DATA: GroupedReviews = { }, answers: null, votes: [ - { content: '반대 의견을 내더라도 듣는 사람이 기분 나쁘지 않게 이야기해요', count: 5 }, - { content: '팀원들의 의견을 잘 모아서 회의가 매끄럽게 진행되도록 해요', count: 4 }, - { content: '팀의 분위기를 주도해요', count: 3 }, - { content: '주장을 이야기할 때에는 합당한 근거가 뒤따라요', count: 2 }, - { content: '팀에게 필요한 것과 그렇지 않은 것을 잘 구분해요', count: 2 }, + { content: '반대 의견을 내더라도 듣는 사람이 기분 나쁘지 않게 이야기해요', count: 13 }, + { content: '팀원들의 의견을 잘 모아서 회의가 매끄럽게 진행되도록 해요', count: 0 }, + { content: '팀의 분위기를 주도해요', count: 5 }, + { content: '주장을 이야기할 때에는 합당한 근거가 뒤따라요', count: 3 }, + { content: '팀에게 필요한 것과 그렇지 않은 것을 잘 구분해요', count: 0 }, { content: '팀 내 주어진 요구사항에 우선순위를 잘 매겨요', count: 1 }, { content: '서로 다른 분야간의 소통도 중요하게 생각해요', count: 1 }, ], diff --git a/frontend/src/mocks/mockData/reviewListMockData.ts b/frontend/src/mocks/mockData/reviewListMockData.ts index 05f5a1d0a..8f9986c3d 100644 --- a/frontend/src/mocks/mockData/reviewListMockData.ts +++ b/frontend/src/mocks/mockData/reviewListMockData.ts @@ -8,7 +8,7 @@ export const REVIEW_LIST: ReviewList = { { reviewId: 5, createdAt: '2024-07-24', - contentPreview: `1. 물론 시중에 출간되어 있는 책들로 공부하는 것도 큰 장점이지만 더 깊은 공부를 하고 싶을 때 공식 문서를 확인해보는 것이 좋기 때문에, 저 개인적인 생각으로는 언어 공부를 아예 처음 입문하시는 분들은 한국에서 출간된 개발 서적으로 공부를 시작하시다가 모르는 부분이.....`, + contentPreview: `1. 나는 짧은 데이터`, categories: [ { optionId: 1, content: '🗣️ 커뮤니케이션, 협업 능력' }, { optionId: 5, content: '🌱 성장 마인드셋' }, @@ -17,7 +17,7 @@ export const REVIEW_LIST: ReviewList = { { reviewId: 2, createdAt: '2023-08-29', - contentPreview: `2. 하루스터디는 효율적인 공부 방법을 제공하는 학습 진행 도구 서비스입니다. 하루스터디는 목표 설정 단계, 학습 단계, 회고 단계를 반복하는 학습 사이클을 통해 학습 효율을 끌어올립니다. 하루스터디를 사용하게 되면 '학습을 잘 하는 방법'에 대해서...`, + contentPreview: `2. 전해주고 싶어 슬픈 시간이 다 흩어진 후에야 들리지만 눈을 감고 느껴봐 움직이는 마음 너를 향한 내 눈빛을 특별한 기적을 기다리지마 눈 앞에선 우리의 거친 길은 알 수 없는 미래와 벽 바꾸지 않아 포기할 수 없어 변치 않을 사랑으로 지켜줘 상처 입은 내 맘까지 시선 속에서 말은 필요 없어 멈춰져 버린 이 시간 사랑해 널 이 느낌 이대로 그려왔던 헤매임의 끝 이 세상 속에서 반복되는 슬픔 이젠 안녕 수많은 알 수 없는 길 속에 희미한 빛을 난 쫓아가 언제까지라도 함께 하는거야 다시 만난 나의 세계`, categories: [ { optionId: 3, content: '⏰ 시간 관리 능력' }, { optionId: 4, content: '🤓 기술적 역량, 전문 지식' }, @@ -26,7 +26,20 @@ export const REVIEW_LIST: ReviewList = { { reviewId: 3, createdAt: '2021-08-01', - contentPreview: `3. 공간을 한 눈에, 예약은 한 번에! 맞춤형 공간예약 서비스 제작 플랫폼 찜꽁입니다! 공간 제공자(관리자)는 에디터를 통해 공간을 생성할 수 있습니다! 생성한 공간은 링크를 통해 사용자에게 제공할 수 있으며, 사용자는 링크를 통해 간편하게 공간을 확인하고 예약을...`, + contentPreview: `3. 'Cause, ah-ah, I'm in the stars tonight + So watch me bring the fire and set the night alight + Shoes on, get up in the morn + Cup of milk, let's rock and roll + King Kong, kick the drum, rolling on like a rolling stone + Sing song when I'm walking home + Jump up to the top, LeBron + Ding dong, call me on my phone + Ice tea and a game of ping pong This is getting heavy + Can you hear the bass boom, I'm ready + Life is sweet as honey + Yeah this beat cha ching like money + Disco overload I'm into that I'm good to go + `, categories: [ { optionId: 5, content: '🌱 성장 마인드셋' }, { optionId: 1, content: '🗣️ 커뮤니케이션, 협업 능력' }, @@ -35,7 +48,18 @@ export const REVIEW_LIST: ReviewList = { { reviewId: 4, createdAt: '2021-08-01', - contentPreview: `4. 공간을 한 눈에, 예약은 한 번에! 맞춤형 공간예약 서비스 제작 플랫폼 찜꽁입니다! 공간 제공자(관리자)는 에디터를 통해 공간을 생성할 수 있습니다! 생성한 공간은 링크를 통해 사용자에게 제공할 수 있으며, 사용자는 링크를 통해 간편하게 공간을 확인하고 예약을...`, + contentPreview: `4. 솔직히, 말할게 많이 기다려 왔어 + 너도 그랬을 거라 믿어 + 오늘이 오길 매일같이 달력을 보면서 + 솔직히, 나에게도, 지금 이 순간은 + 꿈만 같아, 너와 함께라 + 오늘을 위해 꽤 많은 걸 준비해 봤어 + All about you and I, 다른 건 다 제쳐 두고 + Now come with me, take my hand + 아름다운 청춘의 한 장 함께 써내려 가자 + 너와의 추억들로 가득 채울래 (come on!) + 아무 걱정도 하지는 마, 나에게 다 맡겨 봐 + `, categories: [ { optionId: 1, content: '🗣️ 커뮤니케이션, 협업 능력' }, { optionId: 2, content: '💡 문제 해결 능력' }, @@ -44,7 +68,17 @@ export const REVIEW_LIST: ReviewList = { { reviewId: 1, createdAt: '2021-08-01', - contentPreview: `5. 공간을 한 눈에, 예약은 한 번에! 맞춤형 공간예약 서비스 제작 플랫폼 찜꽁입니다! 공간 제공자(관리자)는 에디터를 통해 공간을 생성할 수 있습니다! 생성한 공간은 링크를 통해 사용자에게 제공할 수 있으며, 사용자는 링크를 통해 간편하게 공간을 확인하고 예약을...`, + contentPreview: `5. I'm like some kind of supernova + Watch out + Look at me go, 재미 좀 볼 + 빛의 core, so hot, hot + 문이 열려 서로의 존재를 느껴 + 마치 Discord, 날 닮은 너 (incoming!), 너 누구야? (Drop) + 사건은 다가와, ah-oh, ayy + 거세게 커져가, ah-oh, ayy + That tick, that tick, tick bomb + That tick, that tick, tick bomb + `, categories: [ { optionId: 1, content: '🗣️ 커뮤니케이션, 협업 능력' }, { optionId: 2, content: '💡 문제 해결 능력' }, @@ -53,7 +87,13 @@ export const REVIEW_LIST: ReviewList = { { reviewId: 6, createdAt: '2021-08-01', - contentPreview: `6. 공간을 한 눈에, 예약은 한 번에! 맞춤형 공간예약 서비스 제작 플랫폼 찜꽁입니다! 공간 제공자(관리자)는 에디터를 통해 공간을 생성할 수 있습니다! 생성한 공간은 링크를 통해 사용자에게 제공할 수 있으며, 사용자는 링크를 통해 간편하게 공간을 확인하고 예약을...`, + contentPreview: `6. 네가 참 궁금해 그건 너도 마찬가지 이거면 충분해 쫓고 쫓는 이런 놀이 참을 수 없는 이끌림과 호기심 묘한 너와 나 두고 보면 알겠지 Ooh-ooh, ooh-ooh 눈동자 아래로 Ooh-ooh, ooh-ooh 감추고 있는 거 Narcissistic, my God, I love it + 서로를 비춘 밤 + 아름다운 까만 눈빛 더 빠져 깊이 + (넌 내게로, 난 네게로) + 숨 참고 love dive + Ooh-ooh, ooh-ooh, lalalala-lalala + `, categories: [ { optionId: 5, content: '🌱 성장 마인드셋' }, { optionId: 1, content: '🗣️ 커뮤니케이션, 협업 능력' }, @@ -62,7 +102,7 @@ export const REVIEW_LIST: ReviewList = { { reviewId: 7, createdAt: '2021-08-01', - contentPreview: `7. 공간을 한 눈에, 예약은 한 번에! 맞춤형 공간예약 서비스 제작 플랫폼 찜꽁입니다! 공간 제공자(관리자)는 에디터를 통해 공간을 생성할 수 있습니다! 생성한 공간은 링크를 통해 사용자에게 제공할 수 있으며, 사용자는 링크를 통해 간편하게 공간을 확인하고 예약을...`, + contentPreview: `7. 나는 짧은 데이터`, categories: [ { optionId: 3, content: '⏰ 시간 관리 능력' }, { optionId: 2, content: '💡 문제 해결 능력' }, diff --git a/frontend/src/pages/ReviewCollectionPage/components/DoughnutChart/index.tsx b/frontend/src/pages/ReviewCollectionPage/components/DoughnutChart/index.tsx index 7464323cf..c6b62c229 100644 --- a/frontend/src/pages/ReviewCollectionPage/components/DoughnutChart/index.tsx +++ b/frontend/src/pages/ReviewCollectionPage/components/DoughnutChart/index.tsx @@ -7,35 +7,45 @@ import DoughnutChartDetails from '../DoughnutChartDetails'; import * as S from './styles'; const DOUGHNUT_COLOR = { - START: `${theme.colors.primary}`, - END: '#e7e3f9', + start: `${theme.colors.primary}`, + end: '#e7e3f9', }; +const CHART_RADIUS = 90; +const SVG_VIEWBOX = '0 0 250 250'; +const SVG_SIZE = 250; +const STROKE_WIDTH = 65; + const DoughnutChart = ({ reviewVotes }: { reviewVotes: ReviewVotes[] }) => { - const radius = 90; // 차트의 반지름 - const circumference = 2 * Math.PI * radius; // 차트의 둘레 - const centerX = 125; // svg의 중앙 좌표 (x) - const centerY = 125; // svg의 중앙 좌표 (y) + const circumference = 2 * Math.PI * CHART_RADIUS; // 차트의 둘레 + const centerX = SVG_SIZE / 2; // svg의 중앙 x 좌표 + const centerY = SVG_SIZE / 2; // svg의 중앙 y 좌표 + + const nonZeroReviewVotes = reviewVotes.filter((reviewVote) => reviewVote.count > 0); - const total = reviewVotes.reduce((acc, reviewVote) => acc + reviewVote.count, 0); - const ratios = reviewVotes.map((reviewVote) => reviewVote.count / total); + const totalReviewCount = nonZeroReviewVotes.reduce((acc, reviewVote) => acc + reviewVote.count, 0); + const reviewVoteRatios = nonZeroReviewVotes.map((reviewVote) => reviewVote.count / totalReviewCount); // 누적 값 계산 - const acc = reviewVotes.reduce( + const cumulativeVotes = nonZeroReviewVotes.reduce( (arr, reviewVote) => { - const last = arr[arr.length - 1]; - return [...arr, last + reviewVote.count]; // 현재 값과 이전 누적 값을 더해 새로운 배열 반환 + arr.push(arr[arr.length - 1] + reviewVote.count); + return arr; }, [0], ); // 색상 시작 및 끝값 정의 - const colors = generateGradientColors(reviewVotes.length, DOUGHNUT_COLOR.START, DOUGHNUT_COLOR.END); + const chartColors = generateGradientColors({ + length: reviewVotes.length, + startHex: DOUGHNUT_COLOR.start, + endHex: DOUGHNUT_COLOR.end, + }); // 각 조각의 중심 좌표를 계산하는 함수 const calculateLabelPosition = (startAngle: number, endAngle: number) => { const midAngle = (startAngle + endAngle) / 2; // 중간 각도 - const labelRadius = radius * 1; // 텍스트가 배치될 반지름 (차트 내부) + const labelRadius = CHART_RADIUS * 1; // 텍스트가 배치될 반지름 (차트 내부) const x = centerX + labelRadius * Math.cos((midAngle * Math.PI) / 180); const y = centerY + labelRadius * Math.sin((midAngle * Math.PI) / 180); return { x, y }; @@ -43,16 +53,16 @@ const DoughnutChart = ({ reviewVotes }: { reviewVotes: ReviewVotes[] }) => { return ( - - {reviewVotes.map((reviewVote, index) => { - const ratio = reviewVote.count / total; + + {nonZeroReviewVotes.map((reviewVote, index) => { + const ratio = reviewVote.count / totalReviewCount; const fillSpace = circumference * ratio; const emptySpace = circumference - fillSpace; - const offset = (acc[index] / total) * circumference; + const offset = (cumulativeVotes[index] / totalReviewCount) * circumference; // 시작 각도와 끝 각도를 계산 - const startAngle = (acc[index] / total) * 360 + 90; - const endAngle = ((acc[index] + reviewVote.count) / total) * 360 - 90; + const startAngle = (cumulativeVotes[index] / totalReviewCount) * 360 + 90; + const endAngle = ((cumulativeVotes[index] + reviewVote.count) / totalReviewCount) * 360 - 90; // 비율 레이블의 위치를 계산 const { x, y } = calculateLabelPosition(startAngle, endAngle); @@ -62,21 +72,21 @@ const DoughnutChart = ({ reviewVotes }: { reviewVotes: ReviewVotes[] }) => { - {Math.floor(ratios[index] * 100)}% + {(reviewVoteRatios[index] * 100).toFixed(1)}% ); })} - + ); }; diff --git a/frontend/src/pages/ReviewCollectionPage/index.tsx b/frontend/src/pages/ReviewCollectionPage/index.tsx index 8a93d5a08..d4721335d 100644 --- a/frontend/src/pages/ReviewCollectionPage/index.tsx +++ b/frontend/src/pages/ReviewCollectionPage/index.tsx @@ -19,6 +19,10 @@ const ReviewCollectionPage = () => { const [selectedSection, setSelectedSection] = useState(dropdownSectionList[0]); const { data: groupedReviews } = useGetGroupedReviews({ sectionId: selectedSection.value as number }); + groupedReviews.reviews.forEach((review) => { + review.votes?.sort((voteA, voteB) => voteB.count - voteA.count); + }); + return ( diff --git a/frontend/src/pages/ReviewCollectionPage/utils/generateGradientColors.ts b/frontend/src/pages/ReviewCollectionPage/utils/generateGradientColors.ts index 87059804b..18b1aad97 100644 --- a/frontend/src/pages/ReviewCollectionPage/utils/generateGradientColors.ts +++ b/frontend/src/pages/ReviewCollectionPage/utils/generateGradientColors.ts @@ -1,12 +1,20 @@ +const R_SHIFT = 16; +const G_SHIFT = 8; +const RGB_MAX_VALUE = 255; + // Hex 색상을 RGB로 변환하는 함수 const hexToRGB = (hex: string) => { const bigint = parseInt(hex.slice(1), 16); - return [bigint >> 16, (bigint >> 8) & 255, bigint & 255]; + const r = bigint >> R_SHIFT; + const g = (bigint >> G_SHIFT) & RGB_MAX_VALUE; + const b = bigint & RGB_MAX_VALUE; + + return [r, g, b]; }; // RGB 색상을 Hex로 변환하는 함수 const rgbToHex = (r: number, g: number, b: number) => { - return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase()}`; + return `#${((1 << 24) + (r << R_SHIFT) + (g << G_SHIFT) + b).toString(16).slice(1).toUpperCase()}`; }; // 두 색상 사이의 색상을 계산하는 함수 @@ -15,8 +23,14 @@ const interpolateColor = (start: number[], end: number[], factor: number) => { return result; }; +interface GradientColorProps { + length: number; + startHex: string; + endHex: string; +} + // reviewVotes 길이에 따라 색상 배열을 생성하는 함수 -const generateGradientColors = (length: number, startHex: string, endHex: string) => { +const generateGradientColors = ({ length, startHex, endHex }: GradientColorProps) => { const startColor = hexToRGB(startHex); const endColor = hexToRGB(endHex); const colors = []; From a56cd2959f6ee86ad6e70a42646c45a31d6a9cd0 Mon Sep 17 00:00:00 2001 From: badahertz52 Date: Wed, 16 Oct 2024 17:43:32 +0900 Subject: [PATCH 25/49] =?UTF-8?q?[FE]=20feat:=20=EC=BB=A4=EC=8A=A4?= =?UTF-8?q?=ED=85=80=ED=95=9C=20ErrorBoudary=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EB=B0=8F=20react-error-boundary=20=EC=82=AD=EC=A0=9C=20(#853)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 커스텀한 ErrorBoudary 생성 및 react-error-boundary 삭제 * refactor: fallback 조건문 리팩토링 * refactor: 주석 수정 * feat: resetQueryError 타입 변경 --- frontend/package.json | 1 - .../AuthAndServerErrorFallback/index.tsx | 2 +- .../src/components/error/ErrorBoundary.tsx | 60 +++++++++++++++++++ .../components/error/ErrorFallback/index.tsx | 2 +- .../error/ErrorSuspenseContainer/index.tsx | 4 +- frontend/src/components/error/index.tsx | 1 + frontend/src/constants/errorMessage.ts | 2 + .../CardFormModalContainer/index.tsx | 6 +- .../components/SubmitErrorModal/index.tsx | 3 +- frontend/yarn.lock | 7 --- 10 files changed, 71 insertions(+), 17 deletions(-) create mode 100644 frontend/src/components/error/ErrorBoundary.tsx diff --git a/frontend/package.json b/frontend/package.json index a8b4b52d5..7d044f2d2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,7 +22,6 @@ "dotenv-webpack": "^8.1.0", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-error-boundary": "^4.0.13", "react-router": "^6.24.1", "react-router-dom": "^6.24.1", "recoil": "^0.7.7" diff --git a/frontend/src/components/error/AuthAndServerErrorFallback/index.tsx b/frontend/src/components/error/AuthAndServerErrorFallback/index.tsx index f9bb10f54..475318bc5 100644 --- a/frontend/src/components/error/AuthAndServerErrorFallback/index.tsx +++ b/frontend/src/components/error/AuthAndServerErrorFallback/index.tsx @@ -1,10 +1,10 @@ -import { FallbackProps } from 'react-error-boundary'; import { useNavigate } from 'react-router'; import { ROUTE } from '@/constants/route'; import { useSearchParamAndQuery } from '@/hooks'; import AuthAndServerErrorSection from '../AuthAndServerErrorSection'; +import { FallbackProps } from '../ErrorBoundary'; const AuthAndServerErrorFallback = ({ error, resetErrorBoundary }: FallbackProps) => { const navigate = useNavigate(); diff --git a/frontend/src/components/error/ErrorBoundary.tsx b/frontend/src/components/error/ErrorBoundary.tsx new file mode 100644 index 000000000..2a96e5006 --- /dev/null +++ b/frontend/src/components/error/ErrorBoundary.tsx @@ -0,0 +1,60 @@ +import React, { Component, ReactNode } from 'react'; + +import { ERROR_BOUNDARY_IGNORE_ERROR } from '@/constants'; + +export interface FallbackProps { + error: Error; + resetErrorBoundary: () => void; +} + +interface ErrorBoundaryProps { + fallback: React.ComponentType; + children: ReactNode; + resetQueryError?: () => void; +} + +interface ErrorBoundaryState { + hasError: boolean; + error: Error | null; +} + +class ErrorBoundary extends Component { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + // 에러가 발생하면 상태를 업데이트하여 fallback을 표시 + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + // 에러를 로깅 + console.error('ErrorBoundary caught an error', error, errorInfo); + } + + resetErrorBoundary = () => { + const { resetQueryError } = this.props; + if (resetQueryError) resetQueryError(); + + this.setState({ hasError: false, error: null }); + }; + + render() { + const { hasError, error } = this.state; + const { children, fallback: FallbackComponent } = this.props; + + // 에러 메세지에 IgnoredError를 포함하면 fallback 대상에서 제외 + const isHandleError = !error?.message.includes(ERROR_BOUNDARY_IGNORE_ERROR); + + // 에러가 발생했을 때 fallback 컴포넌트로 대체 + if (hasError && error && isHandleError) { + return ; + } + + return children; + } +} + +export default ErrorBoundary; diff --git a/frontend/src/components/error/ErrorFallback/index.tsx b/frontend/src/components/error/ErrorFallback/index.tsx index 364e980a9..282221c45 100644 --- a/frontend/src/components/error/ErrorFallback/index.tsx +++ b/frontend/src/components/error/ErrorFallback/index.tsx @@ -1,6 +1,6 @@ -import { FallbackProps } from 'react-error-boundary'; import { useNavigate } from 'react-router'; +import { FallbackProps } from '../ErrorBoundary'; import ErrorSection from '../ErrorSection'; const ErrorFallback = ({ error, resetErrorBoundary }: FallbackProps) => { diff --git a/frontend/src/components/error/ErrorSuspenseContainer/index.tsx b/frontend/src/components/error/ErrorSuspenseContainer/index.tsx index cf1674e6a..5752adb6b 100644 --- a/frontend/src/components/error/ErrorSuspenseContainer/index.tsx +++ b/frontend/src/components/error/ErrorSuspenseContainer/index.tsx @@ -1,9 +1,9 @@ import { QueryErrorResetBoundary } from '@tanstack/react-query'; import { lazy, Suspense } from 'react'; -import { ErrorBoundary, FallbackProps } from 'react-error-boundary'; import { EssentialPropsWithChildren } from '@/types'; +import ErrorBoundary, { FallbackProps } from '../ErrorBoundary'; import ErrorFallback from '../ErrorFallback'; const LoadingPage = lazy(() => import('@/pages/LoadingPage')); @@ -19,7 +19,7 @@ const ErrorSuspenseContainer = ({ return ( {({ reset }) => ( - + }>{children} )} diff --git a/frontend/src/components/error/index.tsx b/frontend/src/components/error/index.tsx index 9809c0bd2..1122b20b3 100644 --- a/frontend/src/components/error/index.tsx +++ b/frontend/src/components/error/index.tsx @@ -2,3 +2,4 @@ export { default as ErrorSection } from './ErrorSection'; export { default as ErrorSuspenseContainer } from './ErrorSuspenseContainer'; export { default as AuthAndServerErrorFallback } from './AuthAndServerErrorFallback'; export { default as AuthAndServerErrorSection } from './AuthAndServerErrorSection'; +export { default as ErrorBoundary } from './ErrorBoundary'; diff --git a/frontend/src/constants/errorMessage.ts b/frontend/src/constants/errorMessage.ts index b49822cd8..b2423fc9a 100644 --- a/frontend/src/constants/errorMessage.ts +++ b/frontend/src/constants/errorMessage.ts @@ -17,3 +17,5 @@ export const SERVER_ERROR_REGEX = /^5\d{2}$/; export const ROUTE_ERROR_MESSAGE = '찾으시는 페이지가 없어요'; export const INVALID_REVIEW_PASSWORD_MESSAGE = '올바르지 않은 비밀번호예요'; + +export const ERROR_BOUNDARY_IGNORE_ERROR = 'IgnoredError'; diff --git a/frontend/src/pages/ReviewWritingPage/modals/components/CardFormModalContainer/index.tsx b/frontend/src/pages/ReviewWritingPage/modals/components/CardFormModalContainer/index.tsx index 0b97fd379..8832f2ac3 100644 --- a/frontend/src/pages/ReviewWritingPage/modals/components/CardFormModalContainer/index.tsx +++ b/frontend/src/pages/ReviewWritingPage/modals/components/CardFormModalContainer/index.tsx @@ -1,7 +1,7 @@ import { QueryErrorResetBoundary } from '@tanstack/react-query'; -import { ErrorBoundary } from 'react-error-boundary'; import { useRecoilValue } from 'recoil'; +import { ErrorBoundary } from '@/components'; import { CARD_FORM_MODAL_KEY } from '@/pages/ReviewWritingPage/constants'; import { AnswerListRecheckModal, @@ -31,13 +31,13 @@ const CardFormModalContainer = ({ {({ reset }) => ( ( + fallback={(fallbackProps) => ( closeModal(CARD_FORM_MODAL_KEY.submitConfirm)} {...fallbackProps} /> )} - onReset={reset} + resetQueryError={reset} > {isOpen(CARD_FORM_MODAL_KEY.submitConfirm) && ( Date: Wed, 16 Oct 2024 17:47:16 +0900 Subject: [PATCH 26/49] =?UTF-8?q?[FE]=20design:=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC=20=EA=B8=80=EC=9E=90=20=EA=B0=80=EC=9A=B4=EB=8D=B0=20?= =?UTF-8?q?=EC=A0=95=EB=A0=AC=20(#856)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * design : 리뷰 작성 페이지 모달 속 글자들이 가운데 정렬되게 수정 * design: 가운데 정렬을 위해 align-center, text-align 같이 사용 * design: 375px 이하 에서 리뷰 제출 확인 모달 너비 조정 --- .../modals/components/NavigateBlockerModal/style.ts | 8 ++------ .../modals/components/StrengthUnCheckModal/style.ts | 11 +++++++---- .../modals/components/SubmitCheckModal/style.ts | 8 ++++++-- .../modals/components/SubmitErrorModal/style.ts | 4 ++-- 4 files changed, 17 insertions(+), 14 deletions(-) diff --git a/frontend/src/pages/ReviewWritingPage/modals/components/NavigateBlockerModal/style.ts b/frontend/src/pages/ReviewWritingPage/modals/components/NavigateBlockerModal/style.ts index 1fad5cb28..14d8246fb 100644 --- a/frontend/src/pages/ReviewWritingPage/modals/components/NavigateBlockerModal/style.ts +++ b/frontend/src/pages/ReviewWritingPage/modals/components/NavigateBlockerModal/style.ts @@ -6,19 +6,15 @@ export const ConfirmModalMessage = styled.div` display: flex; flex-direction: column; gap: 0.8rem; - align-items: start; + align-items: center; p { - width: max-content; margin: 0; + text-align: center; } ${media.xSmall} { width: 100%; min-width: 70vw; - - p { - width: inherit; - } } `; diff --git a/frontend/src/pages/ReviewWritingPage/modals/components/StrengthUnCheckModal/style.ts b/frontend/src/pages/ReviewWritingPage/modals/components/StrengthUnCheckModal/style.ts index a0aba46e8..1e6ee027b 100644 --- a/frontend/src/pages/ReviewWritingPage/modals/components/StrengthUnCheckModal/style.ts +++ b/frontend/src/pages/ReviewWritingPage/modals/components/StrengthUnCheckModal/style.ts @@ -6,13 +6,16 @@ export const Contents = styled.div` display: flex; flex-direction: column; gap: 1rem; + align-items: center; + width: max-content; + p { + width: fit-content; + text-align: center; + } ${media.xSmall} { - min-width: ${({ theme }) => { - const { maxWidth, padding } = theme.confirmModalSize; - return `calc(${maxWidth} - (${padding} * 2))`; - }}; + width: 23rem; } `; diff --git a/frontend/src/pages/ReviewWritingPage/modals/components/SubmitCheckModal/style.ts b/frontend/src/pages/ReviewWritingPage/modals/components/SubmitCheckModal/style.ts index 9b3b6c48a..9c6937f1a 100644 --- a/frontend/src/pages/ReviewWritingPage/modals/components/SubmitCheckModal/style.ts +++ b/frontend/src/pages/ReviewWritingPage/modals/components/SubmitCheckModal/style.ts @@ -6,16 +6,20 @@ export const Message = styled.div` display: flex; flex-direction: column; gap: 0.8rem; - align-items: start; + align-items: center; min-width: 30rem; p { - width: inherit; margin: 0; + text-align: center; } ${media.xSmall} { min-width: 27rem; } + + @media screen and (max-width: 375px) { + min-width: 60vw; + } `; diff --git a/frontend/src/pages/ReviewWritingPage/modals/components/SubmitErrorModal/style.ts b/frontend/src/pages/ReviewWritingPage/modals/components/SubmitErrorModal/style.ts index 13eac14bc..d5688c42a 100644 --- a/frontend/src/pages/ReviewWritingPage/modals/components/SubmitErrorModal/style.ts +++ b/frontend/src/pages/ReviewWritingPage/modals/components/SubmitErrorModal/style.ts @@ -4,11 +4,11 @@ export const Message = styled.div` display: flex; flex-direction: column; gap: 0.8rem; - align-items: start; + align-items: center; width: fit-content; p { - width: max-content; margin: 0; + text-align: center; } `; From 51be90420b7e7b72d27b034e61c6a4f486a1a290 Mon Sep 17 00:00:00 2001 From: badahertz52 Date: Thu, 17 Oct 2024 15:35:58 +0900 Subject: [PATCH 27/49] =?UTF-8?q?[FE]=20=20test=20:=20=ED=98=95=EA=B4=91?= =?UTF-8?q?=ED=8E=9C=20API=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=EC=BF=A0=ED=82=A4=20=EA=B4=80=EB=A0=A8?= =?UTF-8?q?=20=EB=AA=A9=20=ED=95=B8=EB=93=A4=EB=9F=AC=20=EB=A6=AC=ED=8C=A9?= =?UTF-8?q?=ED=86=A0=EB=A7=81=20=EB=B0=8F=20=ED=86=A0=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=EC=A4=80=EB=B9=84=20=EC=9E=91=EC=97=85=20?= =?UTF-8?q?=EC=A7=84=ED=96=89=20(#854)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 형광펜 관련 api 파일명 변경(collection -> highlight) * feat: 인증 쿠키 확인하는 목 핸들러 생성 및 적용 * feat: 커스텀한 ErrorBoudary 생성 및 react-error-boundary 삭제 * feat: postHighlight에서 응답이 없는 api 오류도 잡을 수 있도록 수정 * feat: HighlightEditorContainer 생성 - 이유 : 형광펜 api 오류 시, 모아보기 페이지의 ErrorBoundary가 잡으면, 아코디언 상태가 초기화 되어서 HighlightEditor를 별도의 ErrorBoundary로 잡도록 함 * refactor: ErrorBoundary의 fallback 조건문 리팩토링 * feat: postMockHighlight 가 쿠키 인증 하도록 기능 추가 * feat:useMutation을 사용해 형광펜 API 다루도록 함 * feat: 커스텀한 ErrorBoudary 생성 및 react-error-boundary 삭제 * refactor: fallback 조건문 리팩토링 * refactor: 주석 수정 * feat: ErrorBoundary resetQueryError props 타입 변경 * feat: HighlightEditorContainer에 ErrorSuspenseContainer > ErrorBoundary로 변경 * feat: resetQueryError 타입 변경 * feat: 하이라이트 api 요청 데이터 유효성 검사 추가 * test: 하이라이트 api 요청 테스트 추가 * chore: 불필요한 코드 삭제 * test: 형광펜 api 테스트 리팩토링 * refactor: 불필요한 주석 삭제 및 변수명,함수명 변경 * feat: 쿠키 인증 필요한 테스트에서 쿠키 생성 및 삭제하는 유틸 함수 구현 및 적용 * refactor: 테스트용 유틸 폴더 구조 변경 * chore: EditorTestPage 삭제 * refactor: 하이라이트 훅, 컴포넌트 폴더 구조 변경 * refactor: 하이라이트 훅을 HighlightEditor 아래로 위치 이동 및 하이라이트 컴포넌트 export 위치 변경 - HighlightEditorContainer를 src/components/index.tsx에서 export 하도록 수정 * refactor: ErrorBoundary 폴더 구조 변경 --- frontend/src/apis/collection.ts | 38 ------ frontend/src/apis/highlight.ts | 50 ++++++++ .../index.tsx} | 1 - .../EditSwitchButton/index.tsx | 0 .../EditSwitchButton/style.ts | 0 .../EditorLineBlock/index.tsx | 0 .../{ => components}/EditorLineBlock/style.ts | 0 .../HighlightButton/index.tsx | 0 .../{ => components}/HighlightButton/style.ts | 0 .../HighlightEditor/hooks/index.ts} | 1 + .../hooks/useCheckHighlight.ts} | 0 .../HighlightEditor/hooks/useHighlight.ts} | 68 ++++------ .../hooks/useHighlightRemoverPosition.ts} | 0 .../useHighlightToggleButtonPosition.ts} | 0 .../hooks/useMutateHighlight/index.ts | 37 ++++++ .../hooks/useMutateHighlight/test.ts | 46 +++++++ .../HighlightEditor/index.tsx | 22 ++-- .../{ => components}/HighlightEditor/style.ts | 2 +- .../HighlightEditorContainer/index.tsx | 20 +++ .../HighlightRemoverWrapper/index.tsx | 0 .../HighlightToggleButtonContainer/index.tsx | 0 .../{ => components}/Syntax/index.tsx | 0 .../{ => components}/Syntax/style.ts | 0 .../components/highlight/components/index.tsx | 1 + frontend/src/components/index.tsx | 1 + frontend/src/constants/queryKey.ts | 1 + frontend/src/hooks/index.ts | 1 - .../hooks/review/useGetDetailedReview/test.ts | 28 ++--- .../src/hooks/review/useGetReviewList/test.ts | 22 ++-- frontend/src/index.tsx | 5 - frontend/src/mocks/handlers/collection.ts | 11 -- frontend/src/mocks/handlers/cookies.ts | 16 +++ frontend/src/mocks/handlers/highlight.ts | 13 ++ frontend/src/mocks/handlers/index.ts | 4 +- frontend/src/mocks/handlers/review.ts | 116 ++++++++---------- frontend/src/pages/EditorTestPage/index.tsx | 27 ---- .../src/pages/ReviewCollectionPage/index.tsx | 12 +- frontend/src/utils/index.ts | 1 + frontend/src/utils/testUtils/index.ts | 1 + .../src/utils/testUtils/testWithAuthCookie.ts | 15 +++ 40 files changed, 327 insertions(+), 233 deletions(-) delete mode 100644 frontend/src/apis/collection.ts create mode 100644 frontend/src/apis/highlight.ts rename frontend/src/components/error/{ErrorBoundary.tsx => ErrorBoundary/index.tsx} (99%) rename frontend/src/components/highlight/{ => components}/EditSwitchButton/index.tsx (100%) rename frontend/src/components/highlight/{ => components}/EditSwitchButton/style.ts (100%) rename frontend/src/components/highlight/{ => components}/EditorLineBlock/index.tsx (100%) rename frontend/src/components/highlight/{ => components}/EditorLineBlock/style.ts (100%) rename frontend/src/components/highlight/{ => components}/HighlightButton/index.tsx (100%) rename frontend/src/components/highlight/{ => components}/HighlightButton/style.ts (100%) rename frontend/src/{hooks/highlight/index.tsx => components/highlight/components/HighlightEditor/hooks/index.ts} (81%) rename frontend/src/{hooks/highlight/useCheckHighlight.tsx => components/highlight/components/HighlightEditor/hooks/useCheckHighlight.ts} (100%) rename frontend/src/{hooks/highlight/useHighlight.tsx => components/highlight/components/HighlightEditor/hooks/useHighlight.ts} (90%) rename frontend/src/{hooks/highlight/useHighlightRemoverPosition.tsx => components/highlight/components/HighlightEditor/hooks/useHighlightRemoverPosition.ts} (100%) rename frontend/src/{hooks/highlight/useHighlightToggleButtonPosition.tsx => components/highlight/components/HighlightEditor/hooks/useHighlightToggleButtonPosition.ts} (100%) create mode 100644 frontend/src/components/highlight/components/HighlightEditor/hooks/useMutateHighlight/index.ts create mode 100644 frontend/src/components/highlight/components/HighlightEditor/hooks/useMutateHighlight/test.ts rename frontend/src/components/highlight/{ => components}/HighlightEditor/index.tsx (93%) rename frontend/src/components/highlight/{ => components}/HighlightEditor/style.ts (91%) create mode 100644 frontend/src/components/highlight/components/HighlightEditorContainer/index.tsx rename frontend/src/components/highlight/{ => components}/HighlightRemoverWrapper/index.tsx (100%) rename frontend/src/components/highlight/{ => components}/HighlightToggleButtonContainer/index.tsx (100%) rename frontend/src/components/highlight/{ => components}/Syntax/index.tsx (100%) rename frontend/src/components/highlight/{ => components}/Syntax/style.ts (100%) create mode 100644 frontend/src/components/highlight/components/index.tsx delete mode 100644 frontend/src/mocks/handlers/collection.ts create mode 100644 frontend/src/mocks/handlers/cookies.ts create mode 100644 frontend/src/mocks/handlers/highlight.ts delete mode 100644 frontend/src/pages/EditorTestPage/index.tsx create mode 100644 frontend/src/utils/testUtils/index.ts create mode 100644 frontend/src/utils/testUtils/testWithAuthCookie.ts diff --git a/frontend/src/apis/collection.ts b/frontend/src/apis/collection.ts deleted file mode 100644 index 3ba2a2469..000000000 --- a/frontend/src/apis/collection.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { EditorAnswerMap, HighlightPostPayload } from '@/types'; - -import createApiErrorMessage from './apiErrorMessageCreator'; -import endPoint from './endpoints'; - -const transformHighlightData = (editorAnswerMap: EditorAnswerMap, questionId: number): HighlightPostPayload => { - // NOTE: 하이라이트가 있는 답변만 서버에 보내줌 - return { - questionId, - highlights: [...editorAnswerMap.values()] - .filter((answer) => answer.lineList.some((line) => line.highlightList.length > 0)) - .map((answer) => ({ - answerId: answer.answerId, - lines: answer.lineList - .filter((line) => line.highlightList.length > 0) - .map((line) => ({ - index: line.lineIndex, - ranges: line.highlightList, - })), - })), - }; -}; - -export const postHighlight = async (editorAnswerMap: EditorAnswerMap, questionId: number) => { - const postingData = transformHighlightData(editorAnswerMap, questionId); - const response = await fetch(endPoint.postingHighlight, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - credentials: 'include', - body: JSON.stringify(postingData), - }); - - if (!response.ok) { - throw new Error(createApiErrorMessage(response.status)); - } -}; diff --git a/frontend/src/apis/highlight.ts b/frontend/src/apis/highlight.ts new file mode 100644 index 000000000..151109a93 --- /dev/null +++ b/frontend/src/apis/highlight.ts @@ -0,0 +1,50 @@ +import { ERROR_BOUNDARY_IGNORE_ERROR } from '@/constants'; +import { EditorAnswerMap, HighlightPostPayload } from '@/types'; + +import createApiErrorMessage from './apiErrorMessageCreator'; +import endPoint from './endpoints'; + +export const transformHighlightData = (editorAnswerMap: EditorAnswerMap, questionId: number): HighlightPostPayload => { + // NOTE: 하이라이트가 있는 답변만 서버에 보내줌 + return { + questionId, + highlights: [...editorAnswerMap.values()] + .filter((answer) => answer.lineList.some((line) => line.highlightList.length > 0)) + .map((answer) => ({ + answerId: answer.answerId, + lines: answer.lineList + .filter((line) => line.highlightList.length > 0) + .map((line) => ({ + index: line.lineIndex, + ranges: line.highlightList, + })), + })), + }; +}; + +export const isValidPayload = (payload: HighlightPostPayload) => { + return payload.highlights.every((highlight) => highlight.lines.every((line) => line.ranges.length > 0)); +}; + +export const postHighlight = async (editorAnswerMap: EditorAnswerMap, questionId: number) => { + const postingData = transformHighlightData(editorAnswerMap, questionId); + + if (!isValidPayload(postingData)) return console.error('유효하지 않은 형광펜 데이터입니다'); + + try { + const response = await fetch(endPoint.postingHighlight, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify(postingData), + }); + + if (!response.ok) { + throw new Error(ERROR_BOUNDARY_IGNORE_ERROR + createApiErrorMessage(response.status)); + } + } catch (error) { + throw new Error(`${ERROR_BOUNDARY_IGNORE_ERROR}-형광펜 API 요청 실패`); + } +}; diff --git a/frontend/src/components/error/ErrorBoundary.tsx b/frontend/src/components/error/ErrorBoundary/index.tsx similarity index 99% rename from frontend/src/components/error/ErrorBoundary.tsx rename to frontend/src/components/error/ErrorBoundary/index.tsx index 2a96e5006..047a901d8 100644 --- a/frontend/src/components/error/ErrorBoundary.tsx +++ b/frontend/src/components/error/ErrorBoundary/index.tsx @@ -37,7 +37,6 @@ class ErrorBoundary extends Component { resetErrorBoundary = () => { const { resetQueryError } = this.props; if (resetQueryError) resetQueryError(); - this.setState({ hasError: false, error: null }); }; diff --git a/frontend/src/components/highlight/EditSwitchButton/index.tsx b/frontend/src/components/highlight/components/EditSwitchButton/index.tsx similarity index 100% rename from frontend/src/components/highlight/EditSwitchButton/index.tsx rename to frontend/src/components/highlight/components/EditSwitchButton/index.tsx diff --git a/frontend/src/components/highlight/EditSwitchButton/style.ts b/frontend/src/components/highlight/components/EditSwitchButton/style.ts similarity index 100% rename from frontend/src/components/highlight/EditSwitchButton/style.ts rename to frontend/src/components/highlight/components/EditSwitchButton/style.ts diff --git a/frontend/src/components/highlight/EditorLineBlock/index.tsx b/frontend/src/components/highlight/components/EditorLineBlock/index.tsx similarity index 100% rename from frontend/src/components/highlight/EditorLineBlock/index.tsx rename to frontend/src/components/highlight/components/EditorLineBlock/index.tsx diff --git a/frontend/src/components/highlight/EditorLineBlock/style.ts b/frontend/src/components/highlight/components/EditorLineBlock/style.ts similarity index 100% rename from frontend/src/components/highlight/EditorLineBlock/style.ts rename to frontend/src/components/highlight/components/EditorLineBlock/style.ts diff --git a/frontend/src/components/highlight/HighlightButton/index.tsx b/frontend/src/components/highlight/components/HighlightButton/index.tsx similarity index 100% rename from frontend/src/components/highlight/HighlightButton/index.tsx rename to frontend/src/components/highlight/components/HighlightButton/index.tsx diff --git a/frontend/src/components/highlight/HighlightButton/style.ts b/frontend/src/components/highlight/components/HighlightButton/style.ts similarity index 100% rename from frontend/src/components/highlight/HighlightButton/style.ts rename to frontend/src/components/highlight/components/HighlightButton/style.ts diff --git a/frontend/src/hooks/highlight/index.tsx b/frontend/src/components/highlight/components/HighlightEditor/hooks/index.ts similarity index 81% rename from frontend/src/hooks/highlight/index.tsx rename to frontend/src/components/highlight/components/HighlightEditor/hooks/index.ts index 9b38dd7bc..800675d92 100644 --- a/frontend/src/hooks/highlight/index.tsx +++ b/frontend/src/components/highlight/components/HighlightEditor/hooks/index.ts @@ -2,3 +2,4 @@ export { default as useHighlight } from './useHighlight'; export { default as useHighlightToggleButtonPosition } from './useHighlightToggleButtonPosition'; export { default as useCheckHighlight } from './useCheckHighlight'; export { default as useHighlightRemoverPosition } from './useHighlightRemoverPosition'; +export { default as useMutateHighlight } from './useMutateHighlight'; diff --git a/frontend/src/hooks/highlight/useCheckHighlight.tsx b/frontend/src/components/highlight/components/HighlightEditor/hooks/useCheckHighlight.ts similarity index 100% rename from frontend/src/hooks/highlight/useCheckHighlight.tsx rename to frontend/src/components/highlight/components/HighlightEditor/hooks/useCheckHighlight.ts diff --git a/frontend/src/hooks/highlight/useHighlight.tsx b/frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlight.ts similarity index 90% rename from frontend/src/hooks/highlight/useHighlight.tsx rename to frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlight.ts index 6308dff3c..dd9d1484f 100644 --- a/frontend/src/hooks/highlight/useHighlight.tsx +++ b/frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlight.ts @@ -1,6 +1,5 @@ import { useState } from 'react'; -import { postHighlight } from '@/apis/collection'; import { EDITOR_ANSWER_CLASS_NAME, HIGHLIGHT_SPAN_CLASS_NAME } from '@/constants'; import { EditorAnswerMap, EditorLine, Highlight, ReviewAnswerResponseData } from '@/types'; import { @@ -13,6 +12,8 @@ import { EditorSelectionInfo, } from '@/utils'; +import useMutateHighlight from './useMutateHighlight'; + interface UseHighlightProps { questionId: number; answerList: ReviewAnswerResponseData[]; @@ -20,6 +21,7 @@ interface UseHighlightProps { hideHighlightToggleButton: () => void; updateRemoverPosition: (rect: DOMRect) => void; hideRemover: () => void; + handleErrorModal: (isError: boolean) => void; } interface RemovalTarget { @@ -61,37 +63,38 @@ const useHighlight = ({ hideHighlightToggleButton, updateRemoverPosition, hideRemover, + handleErrorModal, }: UseHighlightProps) => { const [editorAnswerMap, setEditorAnswerMap] = useState(makeInitialEditorAnswerMap(answerList)); + // span 클릭 시, 제공되는 형광펜 삭제 기능 타겟 const [removalTarget, setRemovalTarget] = useState(null); - /** - * 선택사항, 토글 버튼 지우기 - */ - const resetSelectionAndButton = () => { + const updateEditorAnswerMap = (newEditorAnswerMap: EditorAnswerMap) => setEditorAnswerMap(newEditorAnswerMap); + + const resetHighlightButton = () => { removeSelection(); hideHighlightToggleButton(); + hideRemover(); + setRemovalTarget(null); }; - const addHighlight = async () => { + const { mutate: mutateHighlight } = useMutateHighlight({ + questionId, + updateEditorAnswerMap, + resetHighlightButton, + handleErrorModal, + }); + + const addHighlight = () => { const selectionInfo = findSelectionInfo(); if (!selectionInfo) return; - const newEditorAnswerMap = selectionInfo.isSameAnswer + const newEditorAnswerMap: EditorAnswerMap | undefined = selectionInfo.isSameAnswer ? addSingleAnswerHighlight(selectionInfo) : addMultipleAnswerHighlight(selectionInfo); if (!newEditorAnswerMap) return; - // TODO: 데이터 요청 후, 성공 시 업데이트 하기 - - try { - await postHighlight(newEditorAnswerMap, questionId); - setEditorAnswerMap(newEditorAnswerMap); - resetSelectionAndButton(); - } catch (error) { - // TODO: 자세한 에러처리는 나중애 - console.error(error); - } + mutateHighlight(newEditorAnswerMap); }; const addMultipleAnswerHighlight = (selectionInfo: EditorSelectionInfo) => { @@ -220,26 +223,17 @@ const useHighlight = ({ return newEditorAnswerMap; }; - const removeHighlightByDrag = async () => { + const removeHighlightByDrag = () => { const selectionInfo = findSelectionInfo(); if (!selectionInfo) return; - const newEditorAnswerMap = selectionInfo.isSameAnswer + const newEditorAnswerMap: EditorAnswerMap | undefined = selectionInfo.isSameAnswer ? removeSingleAnswerHighlight(selectionInfo) : removeMultipleAnswerHighlight(selectionInfo); if (!newEditorAnswerMap) return; - try { - await postHighlight(newEditorAnswerMap, questionId); - - setEditorAnswerMap(newEditorAnswerMap); - // 선택사항, 토글 버튼 지우기 - resetSelectionAndButton(); - } catch (error) { - // 자세한 에러처리는 나중애 - console.error(error); - } + mutateHighlight(newEditorAnswerMap); }; const removeSingleAnswerHighlight = (selectionInfo: EditorSelectionInfo) => { @@ -416,12 +410,12 @@ const useHighlight = ({ updateRemoverPosition(rect); }; - const removeHighlightByClick = async () => { + const removeHighlightByClick = () => { if (!removalTarget) return; const { answerId, lineIndex, highlightIndex } = removalTarget; - const newEditorAnswerMap = new Map(editorAnswerMap); + const newEditorAnswerMap: EditorAnswerMap = new Map(editorAnswerMap); const targetAnswer = newEditorAnswerMap.get(answerId); if (!targetAnswer) return; @@ -435,17 +429,7 @@ const useHighlight = ({ newLineList.splice(lineIndex, 1, newTargetBlock); newEditorAnswerMap.set(answerId, { ...targetAnswer, lineList: newLineList }); - try { - await postHighlight(newEditorAnswerMap, questionId); - setEditorAnswerMap(newEditorAnswerMap); - - // 초기화 - hideRemover(); - setRemovalTarget(null); - } catch (error) { - //TODO: 자세한 에러처리는 나중애 - console.error(error); - } + mutateHighlight(newEditorAnswerMap); }; return { diff --git a/frontend/src/hooks/highlight/useHighlightRemoverPosition.tsx b/frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlightRemoverPosition.ts similarity index 100% rename from frontend/src/hooks/highlight/useHighlightRemoverPosition.tsx rename to frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlightRemoverPosition.ts diff --git a/frontend/src/hooks/highlight/useHighlightToggleButtonPosition.tsx b/frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlightToggleButtonPosition.ts similarity index 100% rename from frontend/src/hooks/highlight/useHighlightToggleButtonPosition.tsx rename to frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlightToggleButtonPosition.ts diff --git a/frontend/src/components/highlight/components/HighlightEditor/hooks/useMutateHighlight/index.ts b/frontend/src/components/highlight/components/HighlightEditor/hooks/useMutateHighlight/index.ts new file mode 100644 index 000000000..167d16005 --- /dev/null +++ b/frontend/src/components/highlight/components/HighlightEditor/hooks/useMutateHighlight/index.ts @@ -0,0 +1,37 @@ +import { useMutation } from '@tanstack/react-query'; + +import { postHighlight } from '@/apis/highlight'; +import { EditorAnswerMap } from '@/types'; + +export interface UseMutateHighlightProps { + questionId: number; + updateEditorAnswerMap: (editorAnswerMap: EditorAnswerMap) => void; + resetHighlightButton: () => void; + handleErrorModal: (isError: boolean) => void; +} + +const useMutateHighlight = ({ + questionId, + handleErrorModal, + updateEditorAnswerMap, + resetHighlightButton, +}: UseMutateHighlightProps) => { + const mutation = useMutation({ + mutationFn: (newEditorAnswerMap: EditorAnswerMap) => postHighlight(newEditorAnswerMap, questionId), + + onSuccess: (_, variables: EditorAnswerMap) => { + updateEditorAnswerMap(variables); + resetHighlightButton(); + // 토스트 모달 지우기 + handleErrorModal(false); + }, + onError: (error) => { + //토스트 모달 띄움 + handleErrorModal(true); + }, + }); + + return mutation; +}; + +export default useMutateHighlight; diff --git a/frontend/src/components/highlight/components/HighlightEditor/hooks/useMutateHighlight/test.ts b/frontend/src/components/highlight/components/HighlightEditor/hooks/useMutateHighlight/test.ts new file mode 100644 index 000000000..a89391aa8 --- /dev/null +++ b/frontend/src/components/highlight/components/HighlightEditor/hooks/useMutateHighlight/test.ts @@ -0,0 +1,46 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { act } from 'react'; + +import { isValidPayload, transformHighlightData } from '@/apis/highlight'; +import QueryClientWrapper from '@/queryTestSetup/QueryClientWrapper'; +import { EditorAnswer, EditorAnswerMap } from '@/types'; +import { testWithAuthCookie } from '@/utils'; + +import useMutateHighlight, { UseMutateHighlightProps } from '.'; + +describe('하이라이트 요청 테스트', () => { + test('API 요청 보내는 데이터가 유효하면(= 하이라이트가 적용된 답변만 보낸다), 하이라이트 요청을 성공한다.', async () => { + const ANSWER: EditorAnswer = { + content: '테스', + answerId: 123, + answerIndex: 0, + lineList: [{ lineIndex: 0, text: '테', highlightList: [{ startIndex: 0, endIndex: 0 }] }], + }; + + const EDITOR_ANSWER_MAP: EditorAnswerMap = new Map([[1, ANSWER]]); + const QUESTION_ID = 1; + + const props: UseMutateHighlightProps = { + questionId: QUESTION_ID, + updateEditorAnswerMap: () => {}, + resetHighlightButton: () => {}, + handleErrorModal: () => {}, + }; + + const testHighlightAPI = async () => { + const data = transformHighlightData(EDITOR_ANSWER_MAP, QUESTION_ID); + expect(isValidPayload(data)).toBeTruthy(); + + const { result } = renderHook(() => useMutateHighlight(props), { + wrapper: QueryClientWrapper, + }); + + await act(async () => { + await result.current.mutateAsync(EDITOR_ANSWER_MAP); + waitFor(() => expect(result.current.isSuccess).toBeTruthy()); + }); + }; + + await testWithAuthCookie(testHighlightAPI); + }); +}); diff --git a/frontend/src/components/highlight/HighlightEditor/index.tsx b/frontend/src/components/highlight/components/HighlightEditor/index.tsx similarity index 93% rename from frontend/src/components/highlight/HighlightEditor/index.tsx rename to frontend/src/components/highlight/components/HighlightEditor/index.tsx index bafcdf538..8007b987d 100644 --- a/frontend/src/components/highlight/HighlightEditor/index.tsx +++ b/frontend/src/components/highlight/components/HighlightEditor/index.tsx @@ -8,12 +8,6 @@ import { HIGHLIGHT__TOGGLE_BUTTON_CLASS_NAME, HIGHLIGHT_REMOVER_CLASS_NAME, } from '@/constants'; -import { - useHighlightToggleButtonPosition, - useHighlight, - useCheckHighlight, - useHighlightRemoverPosition, -} from '@/hooks'; import { ReviewAnswerResponseData } from '@/types'; import { findSelectionInfo } from '@/utils'; @@ -22,6 +16,12 @@ import EditSwitchButton from '../EditSwitchButton'; import HighlightRemoverWrapper from '../HighlightRemoverWrapper'; import HighlightToggleButtonContainer from '../HighlightToggleButtonContainer'; +import { + useHighlightToggleButtonPosition, + useHighlight, + useCheckHighlight, + useHighlightRemoverPosition, +} from './hooks'; import * as S from './style'; const MODE_ICON = { @@ -34,12 +34,13 @@ const MODE_ICON = { alt: '형광펜 기능 꺼짐', }, }; -interface HighlightEditorProps { +export interface HighlightEditorProps { questionId: number; answerList: ReviewAnswerResponseData[]; + handleErrorModal: (isError: boolean) => void; } -const HighlightEditor = ({ questionId, answerList }: HighlightEditorProps) => { +const HighlightEditor = ({ questionId, answerList, handleErrorModal }: HighlightEditorProps) => { const editorRef = useRef(null); const [isEditable, setIsEditable] = useState(false); const { isAddingHighlight, checkHighlight } = useCheckHighlight(); @@ -73,6 +74,7 @@ const HighlightEditor = ({ questionId, answerList }: HighlightEditorProps) => { hideHighlightToggleButton, hideRemover, updateRemoverPosition, + handleErrorModal, }); const handleMouseDown = (e: MouseEvent) => { @@ -104,7 +106,7 @@ const HighlightEditor = ({ questionId, answerList }: HighlightEditorProps) => { }, [isEditable]); return ( - + 형광펜 { {isEditable && removalTarget && removerPosition && ( )} - + ); }; diff --git a/frontend/src/components/highlight/HighlightEditor/style.ts b/frontend/src/components/highlight/components/HighlightEditor/style.ts similarity index 91% rename from frontend/src/components/highlight/HighlightEditor/style.ts rename to frontend/src/components/highlight/components/HighlightEditor/style.ts index f216fc60c..58194a325 100644 --- a/frontend/src/components/highlight/HighlightEditor/style.ts +++ b/frontend/src/components/highlight/components/HighlightEditor/style.ts @@ -1,6 +1,6 @@ import styled from '@emotion/styled'; -export const HighlightEditorContainer = styled.div` +export const HighlightEditor = styled.div` position: relative; display: flex; diff --git a/frontend/src/components/highlight/components/HighlightEditorContainer/index.tsx b/frontend/src/components/highlight/components/HighlightEditorContainer/index.tsx new file mode 100644 index 000000000..66f37a005 --- /dev/null +++ b/frontend/src/components/highlight/components/HighlightEditorContainer/index.tsx @@ -0,0 +1,20 @@ +import { useState } from 'react'; + +import { ErrorBoundary } from '../../../error'; +import ErrorFallback from '../../../error/ErrorFallback'; +import HighlightEditor, { HighlightEditorProps } from '../HighlightEditor'; + +const HighlightEditorContainer = (props: Omit) => { + const [isOpenErrorModal, setIsOpenErrorModal] = useState(false); + const handleErrorModal = (isError: boolean) => setIsOpenErrorModal(isError); + return ( + <> + + + + {isOpenErrorModal &&
오류
} + + ); +}; + +export default HighlightEditorContainer; diff --git a/frontend/src/components/highlight/HighlightRemoverWrapper/index.tsx b/frontend/src/components/highlight/components/HighlightRemoverWrapper/index.tsx similarity index 100% rename from frontend/src/components/highlight/HighlightRemoverWrapper/index.tsx rename to frontend/src/components/highlight/components/HighlightRemoverWrapper/index.tsx diff --git a/frontend/src/components/highlight/HighlightToggleButtonContainer/index.tsx b/frontend/src/components/highlight/components/HighlightToggleButtonContainer/index.tsx similarity index 100% rename from frontend/src/components/highlight/HighlightToggleButtonContainer/index.tsx rename to frontend/src/components/highlight/components/HighlightToggleButtonContainer/index.tsx diff --git a/frontend/src/components/highlight/Syntax/index.tsx b/frontend/src/components/highlight/components/Syntax/index.tsx similarity index 100% rename from frontend/src/components/highlight/Syntax/index.tsx rename to frontend/src/components/highlight/components/Syntax/index.tsx diff --git a/frontend/src/components/highlight/Syntax/style.ts b/frontend/src/components/highlight/components/Syntax/style.ts similarity index 100% rename from frontend/src/components/highlight/Syntax/style.ts rename to frontend/src/components/highlight/components/Syntax/style.ts diff --git a/frontend/src/components/highlight/components/index.tsx b/frontend/src/components/highlight/components/index.tsx new file mode 100644 index 000000000..994608d2e --- /dev/null +++ b/frontend/src/components/highlight/components/index.tsx @@ -0,0 +1 @@ +export { default as HighlightEditorContainer } from './HighlightEditorContainer'; diff --git a/frontend/src/components/index.tsx b/frontend/src/components/index.tsx index 8d8b4e2ad..34bf9c0c8 100644 --- a/frontend/src/components/index.tsx +++ b/frontend/src/components/index.tsx @@ -1,3 +1,4 @@ export * from './layouts'; export * from './common'; export * from './error'; +export * from './highlight/components'; diff --git a/frontend/src/constants/queryKey.ts b/frontend/src/constants/queryKey.ts index 452f55f2b..dfd770355 100644 --- a/frontend/src/constants/queryKey.ts +++ b/frontend/src/constants/queryKey.ts @@ -6,6 +6,7 @@ export const REVIEW_QUERY_KEY = { sectionList: 'sectionList', groupedReviews: 'groupedReviews', reviewInfoData: 'reviewInfoData', + highlight: 'highlight', }; export const GROUP_QUERY_KEY = { diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index 657a1818a..5532776bd 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -9,4 +9,3 @@ export { default as useTopButton } from './useTopButton'; export * from './review'; export * from './reviewGroup'; export * from './modal'; -export * from './highlight'; diff --git a/frontend/src/hooks/review/useGetDetailedReview/test.ts b/frontend/src/hooks/review/useGetDetailedReview/test.ts index d92aa923f..a438ad82b 100644 --- a/frontend/src/hooks/review/useGetDetailedReview/test.ts +++ b/frontend/src/hooks/review/useGetDetailedReview/test.ts @@ -1,30 +1,26 @@ import { renderHook, waitFor } from '@testing-library/react'; -import { MOCK_AUTH_TOKEN_NAME } from '@/mocks/mockData'; import { DETAILED_PAGE_MOCK_API_SETTING_VALUES } from '@/mocks/mockData/detailedReviewMockData'; import QueryClientWrapper from '@/queryTestSetup/QueryClientWrapper'; +import { testWithAuthCookie } from '@/utils'; import useGetDetailedReview from '.'; -// 아래의 테스트는 로그인이 유효하다는 가정하에서 진행 describe('리뷰 상세페이지 데이터 요청 테스트', () => { it('유효힌 id,memberId 사용해야 라뷰 상세 페이지 데이터를 불러온다.', async () => { - // 쿠키 생성 - document.cookie = `${MOCK_AUTH_TOKEN_NAME}=2024-review-me`; + const testReviewDetailAPI = async () => { + const { reviewId } = DETAILED_PAGE_MOCK_API_SETTING_VALUES; + const { result } = renderHook(() => useGetDetailedReview({ reviewId }), { + wrapper: QueryClientWrapper, + }); - const { reviewId } = DETAILED_PAGE_MOCK_API_SETTING_VALUES; - const { result } = renderHook(() => useGetDetailedReview({ reviewId }), { - wrapper: QueryClientWrapper, - }); + await waitFor(() => { + expect(result.current.status).toBe('success'); + }); - await waitFor(() => { - expect(document.cookie).toEqual(`${MOCK_AUTH_TOKEN_NAME}=2024-review-me`); - expect(result.current.status).toBe('success'); - }); + expect(result.current.data).toBeDefined(); + }; - expect(result.current.data).toBeDefined(); - - // 쿠키 삭제 - document.cookie = `${MOCK_AUTH_TOKEN_NAME}=; max-age=-1`; + await testWithAuthCookie(testReviewDetailAPI); }); }); diff --git a/frontend/src/hooks/review/useGetReviewList/test.ts b/frontend/src/hooks/review/useGetReviewList/test.ts index baea33910..52aabc521 100644 --- a/frontend/src/hooks/review/useGetReviewList/test.ts +++ b/frontend/src/hooks/review/useGetReviewList/test.ts @@ -1,23 +1,21 @@ import { renderHook, waitFor } from '@testing-library/react'; -import { MOCK_AUTH_TOKEN_NAME } from '@/mocks/mockData'; import QueryClientWrapper from '@/queryTestSetup/QueryClientWrapper'; +import { testWithAuthCookie } from '@/utils'; import useGetReviewList from './index'; describe('리뷰 목록 페이지 API 연동 테스트', () => { test('성공적으로 데이터를 가져온다', async () => { - //쿠키 생성 - document.cookie = `${MOCK_AUTH_TOKEN_NAME}=2024-review-me`; + const testReviewListAPI = async () => { + const { result } = renderHook(() => useGetReviewList(), { + wrapper: QueryClientWrapper, + }); - const { result } = renderHook(() => useGetReviewList(), { - wrapper: QueryClientWrapper, - }); - - await waitFor(() => { - expect(result.current.isSuccess).toBe(true); - }); + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + }; + await testWithAuthCookie(testReviewListAPI); }); - // 쿠키 삭제 - document.cookie = `${MOCK_AUTH_TOKEN_NAME}=; max-age=-1`; }); diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index 2621ee3b5..732d14e5d 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -11,7 +11,6 @@ import App from '@/App'; import { ErrorSuspenseContainer } from './components'; import { API_ERROR_MESSAGE, ROUTE_PARAM } from './constants'; import { ROUTE } from './constants/route'; -import EditorTestPage from './pages/EditorTestPage'; import globalStyles from './styles/globalStyles'; import theme from './styles/theme'; @@ -98,10 +97,6 @@ const router = createBrowserRouter([
), }, - { - path: `/editor-test`, - element: , - }, { path: `${ROUTE.reviewCollection}/:${ROUTE_PARAM.reviewRequestCode}`, element: }, ], }, diff --git a/frontend/src/mocks/handlers/collection.ts b/frontend/src/mocks/handlers/collection.ts deleted file mode 100644 index 360f85518..000000000 --- a/frontend/src/mocks/handlers/collection.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { http, HttpResponse } from 'msw'; - -import endPoint from '@/apis/endpoints'; - -const postMockHighlight = () => - http.post(endPoint.postingHighlight, async () => { - return HttpResponse.json({ status: 200 }); - }); - -const collectionHandler = [postMockHighlight()]; -export default collectionHandler; diff --git a/frontend/src/mocks/handlers/cookies.ts b/frontend/src/mocks/handlers/cookies.ts new file mode 100644 index 000000000..c986d9bd9 --- /dev/null +++ b/frontend/src/mocks/handlers/cookies.ts @@ -0,0 +1,16 @@ +import { HttpResponse } from 'msw'; + +import { MOCK_AUTH_TOKEN_NAME } from '../mockData'; + +/** + * 쿠키 인증이 필요한 api 요청 시, 쿠키 인증 확인 후 콜백으로 받은 api 목핸들러 작업을 할 수 있게 진행 + * @param callback : 쿠키 인증 확인 후 진행할 api 목핸들러 작업 + */ +export const authorizeWithCookie = (cookies: Record, callback: () => T) => { + if (!cookies[MOCK_AUTH_TOKEN_NAME]) { + return HttpResponse.json({ error: '인증 관련 쿠키 없음' }, { status: 401 }); + } + + // 인증 성공 시 콜백 실행 + return callback(); +}; diff --git a/frontend/src/mocks/handlers/highlight.ts b/frontend/src/mocks/handlers/highlight.ts new file mode 100644 index 000000000..1171c3c29 --- /dev/null +++ b/frontend/src/mocks/handlers/highlight.ts @@ -0,0 +1,13 @@ +import { http, HttpResponse } from 'msw'; + +import endPoint from '@/apis/endpoints'; + +import { authorizeWithCookie } from './cookies'; + +const postMockHighlight = () => + http.post(endPoint.postingHighlight, ({ cookies }) => { + return authorizeWithCookie(cookies, () => HttpResponse.json({ status: 200 })); + }); + +const highlightHandler = [postMockHighlight()]; +export default highlightHandler; diff --git a/frontend/src/mocks/handlers/index.ts b/frontend/src/mocks/handlers/index.ts index 64619b73c..9a224b2c8 100644 --- a/frontend/src/mocks/handlers/index.ts +++ b/frontend/src/mocks/handlers/index.ts @@ -1,7 +1,7 @@ -import collectionHandler from './collection'; import groupHandler from './group'; +import highlightHandler from './highlight'; import reviewHandler from './review'; -const handlers = [...reviewHandler, ...groupHandler, ...collectionHandler]; +const handlers = [...reviewHandler, ...groupHandler, ...highlightHandler]; export default handlers; diff --git a/frontend/src/mocks/handlers/review.ts b/frontend/src/mocks/handlers/review.ts index 678b6d711..44d2bcfc6 100644 --- a/frontend/src/mocks/handlers/review.ts +++ b/frontend/src/mocks/handlers/review.ts @@ -14,111 +14,99 @@ import { REVIEW_REQUEST_CODE, REVIEW_QUESTION_DATA, REVIEW_LIST, - MOCK_AUTH_TOKEN_NAME, MOCK_REVIEW_INFO_DATA, } from '../mockData'; import { GROUPED_REVIEWS_MOCK_DATA, GROUPED_SECTION_MOCK_DATA } from '../mockData/reviewCollection'; +import { authorizeWithCookie } from './cookies'; + export const PAGE = { firstPageNumber: 1, firstPageStartIndex: 0, }; const getReviewInfoData = () => - http.get(endPoint.gettingReviewInfoData, async ({ cookies }) => { - if (!cookies[MOCK_AUTH_TOKEN_NAME]) { - return HttpResponse.json({ error: '인증 관련 쿠키 없음' }, { status: 401 }); - } - - return HttpResponse.json(MOCK_REVIEW_INFO_DATA); + http.get(endPoint.gettingReviewInfoData, ({ cookies }) => { + return authorizeWithCookie(cookies, () => HttpResponse.json(MOCK_REVIEW_INFO_DATA)); }); const getDetailedReview = () => - http.get(new RegExp(`^${DETAILED_REVIEW_API_URL}/\\d+$`), async ({ request, cookies }) => { - // authToken 쿠키 확인 - if (!cookies[MOCK_AUTH_TOKEN_NAME]) { - return HttpResponse.json({ error: '인증 관련 쿠키 없음' }, { status: 401 }); - } + http.get(new RegExp(`^${DETAILED_REVIEW_API_URL}/\\d+$`), ({ request, cookies }) => { + const handleAPI = () => { + //요청 url에서 reviewId, memberId 추출 + const url = new URL(request.url); + const urlReviewId = url.pathname.replace(`/${VERSION2}/${DETAILED_REVIEW_API_PARAMS.resource}/`, ''); - //요청 url에서 reviewId, memberId 추출 - const url = new URL(request.url); - const urlReviewId = url.pathname.replace(`/${VERSION2}/${DETAILED_REVIEW_API_PARAMS.resource}/`, ''); + const { reviewId } = DETAILED_PAGE_MOCK_API_SETTING_VALUES; + // 유효한 reviewId, memberId일 경우에만 데이터 반환 + if (Number(urlReviewId) == reviewId) { + return HttpResponse.json(DETAILED_REVIEW_MOCK_DATA); + } - const { reviewId } = DETAILED_PAGE_MOCK_API_SETTING_VALUES; - // 유효한 reviewId, memberId일 경우에만 데이터 반환 - if (Number(urlReviewId) == reviewId) { - return HttpResponse.json(DETAILED_REVIEW_MOCK_DATA); - } + return HttpResponse.json({ error: '잘못된 상세리뷰 요청' }, { status: 404 }); + }; - return HttpResponse.json({ error: '잘못된 상세리뷰 요청' }, { status: 404 }); + return authorizeWithCookie(cookies, handleAPI); }); const getDataToWriteReview = () => - http.get( - new RegExp(`^${REVIEW_WRITING_API_URL}/${REVIEW_WRITING_API_PARAMS.queryString.write}`), - async ({ request }) => { - //요청 url에서 reviewId, memberId 추출 - const url = new URL(request.url); - const urlRequestCode = url.searchParams.get(REVIEW_WRITING_API_PARAMS.queryString.reviewRequestCode); + http.get(new RegExp(`^${REVIEW_WRITING_API_URL}/${REVIEW_WRITING_API_PARAMS.queryString.write}`), ({ request }) => { + //요청 url에서 reviewId, memberId 추출 + const url = new URL(request.url); + const urlRequestCode = url.searchParams.get(REVIEW_WRITING_API_PARAMS.queryString.reviewRequestCode); - if (REVIEW_REQUEST_CODE === urlRequestCode) { - return HttpResponse.json(REVIEW_QUESTION_DATA); - } - return HttpResponse.json({ error: '잘못된 리뷰 작성 데이터 요청' }, { status: 404 }); - }, - ); + if (REVIEW_REQUEST_CODE === urlRequestCode) { + return HttpResponse.json(REVIEW_QUESTION_DATA); + } + return HttpResponse.json({ error: '잘못된 리뷰 작성 데이터 요청' }, { status: 404 }); + }); // TODO: 추후 getReviewList API에서 리뷰 정보(이름, 개수...)를 내려주지 않는 경우 핸들러도 수정 필요 const getReviewList = (lastReviewId: number | null, size: number) => { - return http.get(endPoint.gettingReviewList(lastReviewId, size), async ({ request, cookies }) => { - // authToken 쿠키 확인 - if (!cookies[MOCK_AUTH_TOKEN_NAME]) return HttpResponse.json({ error: '인증 관련 쿠키 없음' }, { status: 401 }); + return http.get(endPoint.gettingReviewList(lastReviewId, size), ({ request, cookies }) => { + const handleAPI = () => { + const url = new URL(request.url); - const url = new URL(request.url); + const lastReviewIdParam = url.searchParams.get('lastReviewId'); + const lastReviewId = lastReviewIdParam === 'null' ? 0 : Number(lastReviewIdParam); - const lastReviewIdParam = url.searchParams.get('lastReviewId'); - const lastReviewId = lastReviewIdParam === 'null' ? 0 : Number(lastReviewIdParam); + const isFirstPage = lastReviewId === 0; + const startIndex = isFirstPage + ? PAGE.firstPageStartIndex + : REVIEW_LIST.reviews.findIndex((review) => review.reviewId === lastReviewId) + 1; - const isFirstPage = lastReviewId === 0; - const startIndex = isFirstPage - ? PAGE.firstPageStartIndex - : REVIEW_LIST.reviews.findIndex((review) => review.reviewId === lastReviewId) + 1; + const endIndex = startIndex + size; - const endIndex = startIndex + size; + const paginatedReviews = REVIEW_LIST.reviews.slice(startIndex, endIndex); - const paginatedReviews = REVIEW_LIST.reviews.slice(startIndex, endIndex); + const isLastPage = endIndex >= REVIEW_LIST.reviews.length; - const isLastPage = endIndex >= REVIEW_LIST.reviews.length; + return HttpResponse.json({ + revieweeName: REVIEW_LIST.revieweeName, + projectName: REVIEW_LIST.projectName, + lastReviewId: paginatedReviews.length > 0 ? paginatedReviews[paginatedReviews.length - 1].reviewId : 0, + isLastPage: isLastPage, + reviews: paginatedReviews, + }); + }; - return HttpResponse.json({ - revieweeName: REVIEW_LIST.revieweeName, - projectName: REVIEW_LIST.projectName, - lastReviewId: paginatedReviews.length > 0 ? paginatedReviews[paginatedReviews.length - 1].reviewId : 0, - isLastPage: isLastPage, - reviews: paginatedReviews, - }); + return authorizeWithCookie(cookies, handleAPI); }); }; const postReview = () => - http.post(endPoint.postingReview, async () => { + http.post(endPoint.postingReview, () => { return HttpResponse.json({ message: 'post 성공' }, { status: 201 }); }); const getSectionList = () => - http.get(endPoint.gettingSectionList, async ({ request, cookies }) => { - // authToken 쿠키 확인 - if (!cookies[MOCK_AUTH_TOKEN_NAME]) return HttpResponse.json({ error: '인증 관련 쿠키 없음' }, { status: 401 }); - - return HttpResponse.json(GROUPED_SECTION_MOCK_DATA); + http.get(endPoint.gettingSectionList, ({ cookies }) => { + return authorizeWithCookie(cookies, () => HttpResponse.json(GROUPED_SECTION_MOCK_DATA)); }); const getGroupedReviews = (sectionId: number) => - http.get(endPoint.gettingGroupedReviews(sectionId), async ({ request, cookies }) => { - // authToken 쿠키 확인 - if (!cookies[MOCK_AUTH_TOKEN_NAME]) return HttpResponse.json({ error: '인증 관련 쿠키 없음' }, { status: 401 }); - - return HttpResponse.json(GROUPED_REVIEWS_MOCK_DATA); + http.get(endPoint.gettingGroupedReviews(sectionId), ({ cookies }) => { + return authorizeWithCookie(cookies, () => HttpResponse.json(GROUPED_REVIEWS_MOCK_DATA)); }); const reviewHandler = [ diff --git a/frontend/src/pages/EditorTestPage/index.tsx b/frontend/src/pages/EditorTestPage/index.tsx deleted file mode 100644 index 51cc48689..000000000 --- a/frontend/src/pages/EditorTestPage/index.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import HighlightEditor from '@/components/highlight/HighlightEditor'; - -const MOCK_DATA = - '나는 말야, 버릇이 하나있어, 그건 매일 잠에 들 시간마다잘 모아둔 기억 조각들 잡히는 걸 집은 후 혼자 조용히 꼬꼬무\n이걸 난\n이름으로 지었어, 고민,\n아무튼, 뭐, 오늘은 하필이면\n너가 스쳐버려서 우리였을 때로\n우리 정말 좋았던 그때로\n우리의 에피소드가 찬란하게 막을 연다\n배경은 너의 집 앞, 첫 데이트가 끝난\n둘만의 에피소드가 참 예쁜 얘기로 시작\n자작자작, 조심스런 대화, 그새 늦은 시간 조심스런 대화, 그새 늦은 시간 조심스런 대화, 그새 늦은 시간 조심스'; - -const EditorTestPage = () => { - return ( -
-

형광펜 기능 테스트 페이지

- - -
- ); -}; - -export default EditorTestPage; diff --git a/frontend/src/pages/ReviewCollectionPage/index.tsx b/frontend/src/pages/ReviewCollectionPage/index.tsx index d4721335d..5e5464480 100644 --- a/frontend/src/pages/ReviewCollectionPage/index.tsx +++ b/frontend/src/pages/ReviewCollectionPage/index.tsx @@ -1,8 +1,14 @@ import { useState } from 'react'; -import { Accordion, AuthAndServerErrorFallback, Dropdown, ErrorSuspenseContainer, TopButton } from '@/components'; +import { + Accordion, + AuthAndServerErrorFallback, + Dropdown, + ErrorSuspenseContainer, + TopButton, + HighlightEditorContainer, +} from '@/components'; import { DropdownItem } from '@/components/common/Dropdown'; -import HighlightEditor from '@/components/highlight/HighlightEditor'; import ReviewDisplayLayout from '@/components/layouts/ReviewDisplayLayout'; import DoughnutChart from './components/DoughnutChart'; @@ -43,7 +49,7 @@ const ReviewCollectionPage = () => { ) : ( {review.answers && ( - + )} )} diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts index 722d59bb5..88a50dfbc 100644 --- a/frontend/src/utils/index.ts +++ b/frontend/src/utils/index.ts @@ -7,3 +7,4 @@ export { default as substituteString } from './substituteString'; export { default as calculateParticle } from './calculateParticle'; export * from './media'; export * from './highlight/index'; +export * from './testUtils'; diff --git a/frontend/src/utils/testUtils/index.ts b/frontend/src/utils/testUtils/index.ts new file mode 100644 index 000000000..400a3de40 --- /dev/null +++ b/frontend/src/utils/testUtils/index.ts @@ -0,0 +1 @@ +export { default as testWithAuthCookie } from './testWithAuthCookie'; diff --git a/frontend/src/utils/testUtils/testWithAuthCookie.ts b/frontend/src/utils/testUtils/testWithAuthCookie.ts new file mode 100644 index 000000000..1e661fb51 --- /dev/null +++ b/frontend/src/utils/testUtils/testWithAuthCookie.ts @@ -0,0 +1,15 @@ +import { MOCK_AUTH_TOKEN_NAME } from '@/mocks/mockData'; + +const testWithAuthCookie = async (callback: () => Promise | void) => { + // 쿠키 추가 + document.cookie = `${MOCK_AUTH_TOKEN_NAME}=2024-review-me`; + + try { + await callback(); + } finally { + // 쿠키 삭제 + document.cookie = `${MOCK_AUTH_TOKEN_NAME}=; max-age=-1`; + } +}; + +export default testWithAuthCookie; From c551bb869e09955b123bb617dec9bc14c694aaa1 Mon Sep 17 00:00:00 2001 From: ImxYJL <111052302+ImxYJL@users.noreply.github.com> Date: Thu, 17 Oct 2024 16:32:25 +0900 Subject: [PATCH 28/49] =?UTF-8?q?[FE]=20fix:=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=ED=8E=98=EC=9D=B4=EC=A7=80=EC=97=90?= =?UTF-8?q?=EC=84=9C=20preview=EA=B0=80=20=EC=A7=A7=EC=9D=84=20=EB=95=8C?= =?UTF-8?q?=20=EC=8A=A4=ED=83=80=EC=9D=BC=EC=9D=B4=20=EA=B9=A8=EC=A7=80?= =?UTF-8?q?=EB=8D=98=20=ED=98=84=EC=83=81=20=EC=88=98=EC=A0=95=20=EB=B0=8F?= =?UTF-8?q?=20=EB=AA=85=EC=8B=9C=EC=A0=81=EC=9D=B8=20ellipsis=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20(#839)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: preview가 짧을 때 스타일이 깨지던 현상 수정 * feat: 여러 줄(3줄)에 대한 ellipsis 설정 * chore: 리뷰 목록의 일부 모킹 데이터 수정 - 가짜 말줄임표 제거 * chore: 불필요한 속성 제거 * design: 리뷰 목록의 카드 디자인 수정 --- frontend/src/components/ReviewCard/index.tsx | 6 ++-- frontend/src/components/ReviewCard/styles.ts | 32 ++++++-------------- 2 files changed, 13 insertions(+), 25 deletions(-) diff --git a/frontend/src/components/ReviewCard/index.tsx b/frontend/src/components/ReviewCard/index.tsx index 2aba2880d..92baf19a9 100644 --- a/frontend/src/components/ReviewCard/index.tsx +++ b/frontend/src/components/ReviewCard/index.tsx @@ -12,8 +12,9 @@ interface ReviewCardProps { const ReviewCard = ({ createdAt, contentPreview, categories, handleClick }: ReviewCardProps) => { return ( - - + + {createdAt} + {contentPreview} @@ -22,7 +23,6 @@ const ReviewCard = ({ createdAt, contentPreview, categories, handleClick }: Revi
{category.content}
))}
- {createdAt}
diff --git a/frontend/src/components/ReviewCard/styles.ts b/frontend/src/components/ReviewCard/styles.ts index b250ad6bc..5d333823e 100644 --- a/frontend/src/components/ReviewCard/styles.ts +++ b/frontend/src/components/ReviewCard/styles.ts @@ -4,6 +4,7 @@ import media from '@/utils/media'; export const Layout = styled.div` display: flex; + flex-direction: column; border: 0.1rem solid ${({ theme }) => theme.colors.lightGray}; border-radius: 1rem; @@ -17,36 +18,23 @@ export const Layout = styled.div` } `; -export const LeftLineBorder = styled.div` - width: 2.5rem; - background-color: ${({ theme }) => theme.colors.lightGray}; - border-radius: 1rem 0 0 1rem; -`; +export const Header = styled.div` + display: flex; + align-items: center; -export const Title = styled.div` - font-size: 1.6rem; - font-weight: 700; + width: 100%; + height: 3.8rem; + + background-color: ${({ theme }) => theme.colors.lightGray}; + border-radius: 1rem 1rem 0 0; `; export const Date = styled.p` height: fit-content; - padding: 0 1rem; + padding: 0 3rem; font-size: 1.3rem; `; -export const Visibility = styled.div` - display: flex; - gap: 0.6rem; - align-items: center; - - font-size: 1.6rem; - font-weight: 700; - - img { - width: 2rem; - } -`; - export const Main = styled.div` display: flex; flex-direction: column; From b3bbe2be4073b03eb5e2e4acf46c800f1bd66bca Mon Sep 17 00:00:00 2001 From: badahertz52 Date: Thu, 17 Oct 2024 17:35:47 +0900 Subject: [PATCH 29/49] =?UTF-8?q?[FE]=20fix=20:=20=ED=98=95=EA=B4=91?= =?UTF-8?q?=ED=8E=9C=20=EC=9C=84=EC=B9=98,=EB=AA=A8=EB=B0=94=EC=9D=BC=20?= =?UTF-8?q?=EB=8F=99=EC=9E=91=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?(#840)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix : 토글 버튼 위치가 editor 하위 영역을 벗어날 경우 자리는 오류 수정 * refactor: 타입명 변경(EditorSelctionInfo -> SelectionInfo) * refactor: 변수명 변경(block,Block ->line,Line) * refactor: 드래스 시, 나오는 버튼명 변경(dragHighlightButton) * fix: 하이라이트 영역을 길게 클릭/터치해야 해당 하이라이트를 삭제할 수 있도록 변경 * fix:터치 가능한 브라우저에서 드래그 시 버튼 위치 수정 - 터치 가능한 브라우저에서 글자 선택 시 나타나는 브라우저 메뉴와 드래그 끝 방향에 나오는 아이콘으로 인해, 드래그 시 버튼이 가려지는 오류가 발생해 이를 해결 - isTouchDevice를 유틸함수로 분리 * fix: 버튼 그림자가 에디터 영역 밖으로 나가서 잘리는 오류 수정 - 그림자 너비 상수화, 버튼 높이 상수화를 사용하도록 파라미터 수정 * refactor: document에 형광펜 관련 이벤트 적용 및 삭제하는 함수 생성 * refactor: 형광펜에 관한 api 파일명을 collection -> highlight로 변경 * chore: 오타 수정 및 함수명에 Block -> Line으로 수정 * chore: 오타 수정 * chore: EditorTestPage 삭제 * refactor: 하이라이트 훅,컴포넌트 폴더 구조 변경 * refactor: 하이라이트 훅 폴더 위치 변경 - HighlightEditor 아래로 위치 변경 * feat: longPress 삭제버튼 span의 가운데 위치하도록 변경 * feat: 터치 브라우저에서 길게 누를 때 컨텍스트 메뉴 나오지 않게 이벤트 추가 * feat: 터치 관련 클릭 버튼 위치 변경 사항 삭제 * feat: useEditabelState훅 구현 및 isEditable 상태를 세션 스토리지에 저장/삭제하는 기능 추가 - 구현 이유 : api 오류 시, isEditable 상태가 초기화 됨, 상태가 초기화되더라도 이전 상태를 저장해 반영할 수 있도록 기능 구현 * fix: 하이라이트 훅 status 복원 * chore: 불필요한 코드 삭제 * refactor: useHighlightEventListener 훅 생성 --- .../index.tsx | 16 +- .../components/HighlightButton/index.tsx | 29 +-- .../components/HighlightButton/style.ts | 6 +- .../components/HighlightEditor/hooks/index.ts | 7 +- .../hooks/useCheckHighlight.ts | 4 +- .../hooks/useDragHighlightButtonPosition.ts | 193 ++++++++++++++++++ .../HighlightEditor/hooks/useEditableState.ts | 40 ++++ .../HighlightEditor/hooks/useHighlight.ts | 79 ++++--- .../hooks/useHighlightEventListener.ts | 85 ++++++++ .../hooks/useHighlightRemoverPosition.ts | 45 ---- .../hooks/useHighlightToggleButtonPosition.ts | 75 ------- .../HighlightEditor/hooks/useLongPress.ts | 32 +++ .../useLongPressHighlightButtonPosition.ts | 50 +++++ .../components/HighlightEditor/index.tsx | 109 +++++----- .../HighlightRemoverWrapper/index.tsx | 16 -- .../LongPressHighlightButtonWrapper/index.tsx | 24 +++ .../highlight/components/Syntax/style.ts | 3 +- frontend/src/constants/highlight.ts | 3 +- frontend/src/constants/index.ts | 1 + frontend/src/constants/sessionStorageKey.ts | 3 + frontend/src/mocks/handlers/highlight.ts | 2 +- frontend/src/utils/highlight/highlighList.ts | 4 +- frontend/src/utils/highlight/selection.ts | 135 ++++++------ frontend/src/utils/index.ts | 3 +- frontend/src/utils/touchDevice.ts | 8 + 25 files changed, 632 insertions(+), 340 deletions(-) rename frontend/src/components/highlight/components/{HighlightToggleButtonContainer => DragHighlightButtonContainer}/index.tsx (50%) create mode 100644 frontend/src/components/highlight/components/HighlightEditor/hooks/useDragHighlightButtonPosition.ts create mode 100644 frontend/src/components/highlight/components/HighlightEditor/hooks/useEditableState.ts create mode 100644 frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlightEventListener.ts delete mode 100644 frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlightRemoverPosition.ts delete mode 100644 frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlightToggleButtonPosition.ts create mode 100644 frontend/src/components/highlight/components/HighlightEditor/hooks/useLongPress.ts create mode 100644 frontend/src/components/highlight/components/HighlightEditor/hooks/useLongPressHighlightButtonPosition.ts delete mode 100644 frontend/src/components/highlight/components/HighlightRemoverWrapper/index.tsx create mode 100644 frontend/src/components/highlight/components/LongPressHighlightButtonWrapper/index.tsx create mode 100644 frontend/src/constants/sessionStorageKey.ts create mode 100644 frontend/src/utils/touchDevice.ts diff --git a/frontend/src/components/highlight/components/HighlightToggleButtonContainer/index.tsx b/frontend/src/components/highlight/components/DragHighlightButtonContainer/index.tsx similarity index 50% rename from frontend/src/components/highlight/components/HighlightToggleButtonContainer/index.tsx rename to frontend/src/components/highlight/components/DragHighlightButtonContainer/index.tsx index 6830091cb..5256cfb62 100644 --- a/frontend/src/components/highlight/components/HighlightToggleButtonContainer/index.tsx +++ b/frontend/src/components/highlight/components/DragHighlightButtonContainer/index.tsx @@ -2,30 +2,30 @@ import { Position } from '@/types'; import HighlightButton from '../HighlightButton'; -interface HighlightToggleButtonContainerProps { +interface DragHighlightButtonContainerProps { buttonPosition: Position; isAddingHighlight: boolean; - addHighlight: () => void; + addHighlightByDrag: () => void; removeHighlightByDrag: () => void; } /** *선택된 영역의 하이라이트 적용 여부에 따라 추가 또는 삭제 버튼을 보여주는 컴포넌트 */ -const HighlightToggleButtonContainer = ({ +const DragHighlightButtonContainer = ({ buttonPosition, isAddingHighlight, - addHighlight, + addHighlightByDrag, removeHighlightByDrag, -}: HighlightToggleButtonContainerProps) => { +}: DragHighlightButtonContainerProps) => { return ( <> {isAddingHighlight ? ( - + ) : ( - + )} ); }; -export default HighlightToggleButtonContainer; +export default DragHighlightButtonContainer; diff --git a/frontend/src/components/highlight/components/HighlightButton/index.tsx b/frontend/src/components/highlight/components/HighlightButton/index.tsx index b78b7357a..1e8cdd8fb 100644 --- a/frontend/src/components/highlight/components/HighlightButton/index.tsx +++ b/frontend/src/components/highlight/components/HighlightButton/index.tsx @@ -11,16 +11,16 @@ import { Position } from '@/types'; import * as S from './style'; -interface HighlighterButtonProps { +interface DragHighlightAddButtonProps { position: Position; - addHighlight: () => void; + addHighlightByDrag: () => void; } -const HighlighterButton = ({ addHighlight, position }: HighlighterButtonProps) => { +const DragHighlightAddButton = ({ addHighlightByDrag, position }: DragHighlightAddButtonProps) => { return ( @@ -31,12 +31,12 @@ const HighlighterButton = ({ addHighlight, position }: HighlighterButtonProps) = ); }; -interface HighlightDragRemovalProps { +interface DragHighlightRemoveButtonProps { removeHighlightByDrag: () => void; position: Position; } -const HighlightDragRemoval = ({ removeHighlightByDrag, position }: HighlightDragRemovalProps) => { +const DragHighlightRemoveButton = ({ removeHighlightByDrag, position }: DragHighlightRemoveButtonProps) => { return ( void; +interface LongPressHighlightRemoveButtonProps { + removeHighlightByLongPress: () => void; position: Position; } -const HighlightClickRemoval = ({ removeHighlightByClick, position }: HighlightClickRemovalProps) => { +const LongPressHighlightRemoveButton = ({ + removeHighlightByLongPress, + position, +}: LongPressHighlightRemoveButtonProps) => { return ( @@ -70,9 +73,9 @@ const HighlightClickRemoval = ({ removeHighlightByClick, position }: HighlightCl }; const HighlightButton = { - highlighter: HighlighterButton, - dragRemoval: HighlightDragRemoval, - clickRemoval: HighlightClickRemoval, + dragHighlightAdd: DragHighlightAddButton, + dragHighlightRemove: DragHighlightRemoveButton, + longPressHighlightRemove: LongPressHighlightRemoveButton, }; export default HighlightButton; diff --git a/frontend/src/components/highlight/components/HighlightButton/style.ts b/frontend/src/components/highlight/components/HighlightButton/style.ts index 57dd8f3cc..660dfef32 100644 --- a/frontend/src/components/highlight/components/HighlightButton/style.ts +++ b/frontend/src/components/highlight/components/HighlightButton/style.ts @@ -1,5 +1,6 @@ import styled from '@emotion/styled'; +import { HIGHLIGHT_BUTTON_SIZE } from '@/constants'; import { Position } from '@/types'; export const Button = styled.button<{ $position: Position; $width: number }>` @@ -11,12 +12,13 @@ export const Button = styled.button<{ $position: Position; $width: number }>` gap: 0.8rem; width: ${(props) => `${props.$width / 10}rem`}; + height: ${() => `${HIGHLIGHT_BUTTON_SIZE.height / 10}rem`}; padding: 0.5rem 0.8rem; background-color: ${({ theme }) => theme.colors.white}; border-radius: ${({ theme }) => theme.borderRadius.basic}; - -webkit-box-shadow: 0 0 1.4rem -0.2rem #343434; - box-shadow: 0 0 1.4rem -0.2rem #343434; + -webkit-box-shadow: 0 0 ${() => `${HIGHLIGHT_BUTTON_SIZE.shadow / 10}rem`} -0.2rem #343434b8; + box-shadow: 0 0 ${() => `${HIGHLIGHT_BUTTON_SIZE.shadow / 10}rem`} -0.2rem #343434b8; &:hover { background-color: ${({ theme }) => theme.colors.palePurple}; diff --git a/frontend/src/components/highlight/components/HighlightEditor/hooks/index.ts b/frontend/src/components/highlight/components/HighlightEditor/hooks/index.ts index 800675d92..8d1e0da7a 100644 --- a/frontend/src/components/highlight/components/HighlightEditor/hooks/index.ts +++ b/frontend/src/components/highlight/components/HighlightEditor/hooks/index.ts @@ -1,5 +1,8 @@ export { default as useHighlight } from './useHighlight'; -export { default as useHighlightToggleButtonPosition } from './useHighlightToggleButtonPosition'; +export { default as useDragHighlightButtonPosition } from './useDragHighlightButtonPosition'; export { default as useCheckHighlight } from './useCheckHighlight'; -export { default as useHighlightRemoverPosition } from './useHighlightRemoverPosition'; +export { default as useLongPressHighlightButtonPosition } from './useLongPressHighlightButtonPosition'; +export { default as useLongPress } from './useLongPress'; export { default as useMutateHighlight } from './useMutateHighlight'; +export { default as useEditableState } from './useEditableState'; +export { default as useHighlightEventListener } from './useHighlightEventListener'; diff --git a/frontend/src/components/highlight/components/HighlightEditor/hooks/useCheckHighlight.ts b/frontend/src/components/highlight/components/HighlightEditor/hooks/useCheckHighlight.ts index 13bb1dbf3..36c2926ed 100644 --- a/frontend/src/components/highlight/components/HighlightEditor/hooks/useCheckHighlight.ts +++ b/frontend/src/components/highlight/components/HighlightEditor/hooks/useCheckHighlight.ts @@ -1,12 +1,12 @@ import { useState } from 'react'; import { HIGHLIGHT_SPAN_CLASS_NAME, SYNTAX_BASIC_CLASS_NAME } from '@/constants'; -import { EditorSelectionInfo } from '@/utils'; +import { SelectionInfo } from '@/utils'; const useCheckHighlight = () => { const [isAddingHighlight, setIsAddingHighlight] = useState(false); - const checkHighlight = (info: EditorSelectionInfo) => { + const checkHighlight = (info: SelectionInfo) => { const selectedAllSpanList = getAllSpanInSelection(info.selection); const isNoneHighlight = selectedAllSpanList.some((span) => !span.classList.contains(HIGHLIGHT_SPAN_CLASS_NAME)); diff --git a/frontend/src/components/highlight/components/HighlightEditor/hooks/useDragHighlightButtonPosition.ts b/frontend/src/components/highlight/components/HighlightEditor/hooks/useDragHighlightButtonPosition.ts new file mode 100644 index 000000000..fc86c10c7 --- /dev/null +++ b/frontend/src/components/highlight/components/HighlightEditor/hooks/useDragHighlightButtonPosition.ts @@ -0,0 +1,193 @@ +import { useLayoutEffect, useState } from 'react'; + +import { GAP_WIDTH_SELECTION_AND_HIGHLIGHT_BUTTON, HIGHLIGHT_BUTTON_SIZE } from '@/constants'; +import { Position } from '@/types'; +import { isTouchDevice, SelectionInfo } from '@/utils'; + +interface UseDragButtonPositionProps { + isEditable: boolean; + editorRef: React.RefObject; + hideLongPressHighlightButton: () => void; +} + +export interface getDragHighlightButtonParams { + selectionInfo: SelectionInfo; + isAddingHighlight: boolean; +} + +const useDragHighlightButtonPosition = ({ + isEditable, + editorRef, + hideLongPressHighlightButton, +}: UseDragButtonPositionProps) => { + const [dragHighlightButtonPosition, setDragHighlightButtonPosition] = useState(null); + + const hideDragHighlightButton = () => setDragHighlightButtonPosition(null); + + //위치 계산 + interface GetRectsParams { + selectionInfo: SelectionInfo; + editorRef: React.RefObject; + } + /** + * 드래그 시 마지막으로 선택된 Node와 editor의 DOMRect를 반환하는 함수 + */ + const getRects = ({ selectionInfo, editorRef }: GetRectsParams) => { + if (!editorRef.current) return console.error('editorRef 값이 없어요.'); + + const { selection, isForwardDrag } = selectionInfo; + const range = selection.getRangeAt(0); + const rects = range.getClientRects(); + const editorRect = editorRef.current.getClientRects()[0]; + + if (rects.length === 0) return console.error('선택된 글자가 없어요.'); + + const lastRect = rects[isForwardDrag ? rects.length - 1 : 0]; + + return { + editorRect, + lastRect, + }; + }; + + /** + * + * @param lastRect 드래그 시 마지막으로 선택된 Node의 DOMRect + * @param editorRect editor DOMRect + * @param isForwardDrag 드래그가 정방향인지 여부 + */ + const calculateRectOffsets = ( + lastRect: DOMRect, + editorRect: DOMRect, + isForwardDrag: boolean, + buttonWidth: number, + ) => { + const { height: buttonHeight } = HIGHLIGHT_BUTTON_SIZE; + const isTouch = isTouchDevice(); + //뷰포트 기준 위치 + const rectLeft = isForwardDrag ? lastRect.right - (isTouch ? buttonWidth : 0) : lastRect.left; + const rectTop = isForwardDrag + ? lastRect.bottom + GAP_WIDTH_SELECTION_AND_HIGHLIGHT_BUTTON + : lastRect.top - buttonHeight - GAP_WIDTH_SELECTION_AND_HIGHLIGHT_BUTTON; + + // 에디터 기준 위치 + const leftOffsetFromEditor = rectLeft - editorRect.left; + const topOffsetFromEditor = rectTop - editorRect.top; + + return { leftOffsetFromEditor, topOffsetFromEditor, rectLeft, rectTop }; + }; + + /** + * 토글 버튼이 editor를 영역을 벗어나는지 여부를 계산하는 함수 + * @param rectLeft 토글 버튼의 뷰기준 left 위치 + * @param rectTop 토클 버튼의 뷰기준 top 위치 + * @param buttonWidth 토글 버튼의 width + * @param editorRect editor DOMRect + */ + const checkOverflow = (rectLeft: number, rectTop: number, buttonWidth: number, editorRect: DOMRect) => { + const { shadow: shadowWidth, height: buttonHeight } = HIGHLIGHT_BUTTON_SIZE; + const isOverflowingHorizontally = editorRect.right < rectLeft + buttonWidth + shadowWidth; + const isOverflowingVertically = editorRect.bottom < rectTop + buttonHeight; + + return { isOverflowingHorizontally, isOverflowingVertically }; + }; + + interface CalculateDragHighlightButtonPosition { + leftOffsetFromEditor: number; + topOffsetFromEditor: number; + buttonWidth: number; + isOverflowingHorizontally: boolean; + isOverflowingVertically: boolean; + editorRect: DOMRect; + lastRect: DOMRect; + } + const calculateDragHighlightButtonPosition = ({ + leftOffsetFromEditor, + topOffsetFromEditor, + buttonWidth, + isOverflowingHorizontally, + isOverflowingVertically, + editorRect, + lastRect, + }: CalculateDragHighlightButtonPosition) => { + const { height: buttonHeight, shadow: shadowWidth } = HIGHLIGHT_BUTTON_SIZE; + + const left = isOverflowingHorizontally ? editorRect.width - buttonWidth - shadowWidth : leftOffsetFromEditor; + const top = isOverflowingVertically + ? topOffsetFromEditor - lastRect.height - GAP_WIDTH_SELECTION_AND_HIGHLIGHT_BUTTON * 2 - buttonHeight + : topOffsetFromEditor; + + return { left, top }; + }; + + const getButtonWidth = (isAddingHighlight: boolean) => { + const { basic: buttonBasicWidth, buttonWidthColor: addButtonWidth } = HIGHLIGHT_BUTTON_SIZE.width; + const buttonWidth = isAddingHighlight ? addButtonWidth : buttonBasicWidth; + + return { + buttonWidth, + }; + }; + + const getDragHighlightButtonPosition = ({ selectionInfo, isAddingHighlight }: getDragHighlightButtonParams) => { + const { isForwardDrag } = selectionInfo; + + const rects = getRects({ selectionInfo, editorRef }); + if (!rects) return; + + const { lastRect, editorRect } = rects; + const { buttonWidth } = getButtonWidth(isAddingHighlight); + + const { leftOffsetFromEditor, topOffsetFromEditor, rectLeft, rectTop } = calculateRectOffsets( + lastRect, + editorRect, + isForwardDrag, + buttonWidth, + ); + const { isOverflowingHorizontally, isOverflowingVertically } = checkOverflow( + rectLeft, + rectTop, + buttonWidth, + + editorRect, + ); + const { left, top } = calculateDragHighlightButtonPosition({ + leftOffsetFromEditor, + topOffsetFromEditor, + buttonWidth, + isOverflowingHorizontally, + isOverflowingVertically, + editorRect, + lastRect, + }); + + const position: Position = { + left: `${left / 10}rem`, + top: `${top / 10}rem`, + }; + + return position; + }; + + const updateDragHighlightButtonPosition = ({ selectionInfo, isAddingHighlight }: getDragHighlightButtonParams) => { + const position = getDragHighlightButtonPosition({ selectionInfo, isAddingHighlight }); + if (!position) return console.error('endPosition을 찾을 수 없어요.'); + + setDragHighlightButtonPosition(position); + hideLongPressHighlightButton(); + }; + + useLayoutEffect(() => { + if (!isEditable) hideDragHighlightButton(); + }, [isEditable]); + + useLayoutEffect(() => {}); + + return { + dragHighlightButtonPosition, + hideDragHighlightButton, + updateDragHighlightButtonPosition, + }; +}; + +export default useDragHighlightButtonPosition; diff --git a/frontend/src/components/highlight/components/HighlightEditor/hooks/useEditableState.ts b/frontend/src/components/highlight/components/HighlightEditor/hooks/useEditableState.ts new file mode 100644 index 000000000..3dd242b6b --- /dev/null +++ b/frontend/src/components/highlight/components/HighlightEditor/hooks/useEditableState.ts @@ -0,0 +1,40 @@ +import { useEffect, useState } from 'react'; + +import { SESSION_STORAGE_KEY } from '@/constants'; + +const useEditableState = () => { + const [isEditable, setIsEditable] = useState(false); + + const getHighlightEditorStateInStorage = () => sessionStorage.getItem(SESSION_STORAGE_KEY.isHighlightEditable); + + const saveHighlightEditorStateInStorage = () => { + sessionStorage.setItem(SESSION_STORAGE_KEY.isHighlightEditable, 'true'); + }; + + const removeHighlightEditorStateFromStorage = () => { + sessionStorage.removeItem(SESSION_STORAGE_KEY.isHighlightEditable); + }; + + const handleEditToggleButton = () => { + setIsEditable((prev) => { + prev ? removeHighlightEditorStateFromStorage() : saveHighlightEditorStateInStorage(); + return !prev; + }); + }; + + useEffect(() => { + const storageItem = getHighlightEditorStateInStorage(); + if (storageItem) setIsEditable(true); + + return () => { + removeHighlightEditorStateFromStorage(); + }; + }, []); + + return { + isEditable, + handleEditToggleButton, + }; +}; + +export default useEditableState; diff --git a/frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlight.ts b/frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlight.ts index dd9d1484f..bad65a09f 100644 --- a/frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlight.ts +++ b/frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlight.ts @@ -3,13 +3,13 @@ import { useState } from 'react'; import { EDITOR_ANSWER_CLASS_NAME, HIGHLIGHT_SPAN_CLASS_NAME } from '@/constants'; import { EditorAnswerMap, EditorLine, Highlight, ReviewAnswerResponseData } from '@/types'; import { - getEndBlockOffset, - getStartBlockOffset, + getEndLineOffset, + getStartLineOffset, getRemovedHighlightList, findSelectionInfo, getUpdatedBlockByHighlight, removeSelection, - EditorSelectionInfo, + SelectionInfo, } from '@/utils'; import useMutateHighlight from './useMutateHighlight'; @@ -18,9 +18,9 @@ interface UseHighlightProps { questionId: number; answerList: ReviewAnswerResponseData[]; isEditable: boolean; - hideHighlightToggleButton: () => void; - updateRemoverPosition: (rect: DOMRect) => void; - hideRemover: () => void; + hideDragHighlightButton: () => void; + updateLongPressHighlightButtonPosition: (rect: DOMRect) => void; + hideLongPressHighlightButton: () => void; handleErrorModal: (isError: boolean) => void; } @@ -60,9 +60,9 @@ const useHighlight = ({ questionId, answerList, isEditable, - hideHighlightToggleButton, - updateRemoverPosition, - hideRemover, + hideDragHighlightButton, + updateLongPressHighlightButtonPosition, + hideLongPressHighlightButton, handleErrorModal, }: UseHighlightProps) => { const [editorAnswerMap, setEditorAnswerMap] = useState(makeInitialEditorAnswerMap(answerList)); @@ -74,9 +74,7 @@ const useHighlight = ({ const resetHighlightButton = () => { removeSelection(); - hideHighlightToggleButton(); - hideRemover(); - setRemovalTarget(null); + hideDragHighlightButton(); }; const { mutate: mutateHighlight } = useMutateHighlight({ @@ -86,7 +84,7 @@ const useHighlight = ({ handleErrorModal, }); - const addHighlight = () => { + const addHighlightByDrag = () => { const selectionInfo = findSelectionInfo(); if (!selectionInfo) return; const newEditorAnswerMap: EditorAnswerMap | undefined = selectionInfo.isSameAnswer @@ -97,7 +95,7 @@ const useHighlight = ({ mutateHighlight(newEditorAnswerMap); }; - const addMultipleAnswerHighlight = (selectionInfo: EditorSelectionInfo) => { + const addMultipleAnswerHighlight = (selectionInfo: SelectionInfo) => { const { startAnswer, endAnswer } = selectionInfo; const newEditorAnswerMap = new Map(editorAnswerMap); if (!startAnswer || !endAnswer) return; @@ -177,8 +175,8 @@ const useHighlight = ({ return newEditorAnswerMap; }; - const addSingleAnswerHighlight = (selectionInfo: EditorSelectionInfo) => { - const { startBlockIndex, endBlockIndex, startAnswer } = selectionInfo; + const addSingleAnswerHighlight = (selectionInfo: SelectionInfo) => { + const { startLineIndex, endLineIndex, startAnswer } = selectionInfo; if (!startAnswer) return; const newEditorAnswerMap = new Map(editorAnswerMap); @@ -188,10 +186,10 @@ const useHighlight = ({ if (!targetAnswer) return; const newLineList: EditorLine[] = targetAnswer.lineList.map((block, index, array) => { - if (index < startBlockIndex) return block; - if (index > endBlockIndex) return block; - if (index === startBlockIndex) { - const { startIndex, endIndex } = getStartBlockOffset(selectionInfo, block); + if (index < startLineIndex) return block; + if (index > endLineIndex) return block; + if (index === startLineIndex) { + const { startIndex, endIndex } = getStartLineOffset(selectionInfo, block); return getUpdatedBlockByHighlight({ blockTextLength: block.text.length, @@ -202,8 +200,8 @@ const useHighlight = ({ }); } - if (index === endBlockIndex) { - const endIndex = getEndBlockOffset(selectionInfo); + if (index === endLineIndex) { + const endIndex = getEndLineOffset(selectionInfo); return getUpdatedBlockByHighlight({ blockTextLength: block.text.length, @@ -236,8 +234,8 @@ const useHighlight = ({ mutateHighlight(newEditorAnswerMap); }; - const removeSingleAnswerHighlight = (selectionInfo: EditorSelectionInfo) => { - const { startBlockIndex, endBlockIndex, startAnswer } = selectionInfo; + const removeSingleAnswerHighlight = (selectionInfo: SelectionInfo) => { + const { startLineIndex, endLineIndex, startAnswer } = selectionInfo; if (!startAnswer) return; const newEditorAnswerMap = new Map(editorAnswerMap); @@ -247,10 +245,10 @@ const useHighlight = ({ if (!targetAnswer) return; const newLineList = targetAnswer.lineList.map((line, index) => { - if (index < startBlockIndex) return line; - if (index > endBlockIndex) return line; - if (index === startBlockIndex) { - const { startIndex, endIndex } = getStartBlockOffset(selectionInfo, line); + if (index < startLineIndex) return line; + if (index > endLineIndex) return line; + if (index === startLineIndex) { + const { startIndex, endIndex } = getStartLineOffset(selectionInfo, line); return { ...line, @@ -262,8 +260,8 @@ const useHighlight = ({ }), }; } - if (index === endBlockIndex) { - const endIndex = getEndBlockOffset(selectionInfo); + if (index === endLineIndex) { + const endIndex = getEndLineOffset(selectionInfo); return { ...line, highlightList: getRemovedHighlightList({ @@ -284,7 +282,7 @@ const useHighlight = ({ return newEditorAnswerMap; }; - const removeMultipleAnswerHighlight = (selectionInfo: EditorSelectionInfo) => { + const removeMultipleAnswerHighlight = (selectionInfo: SelectionInfo) => { const { startAnswer, endAnswer } = selectionInfo; const newEditorAnswerMap = new Map(editorAnswerMap); if (!startAnswer || !endAnswer) return; @@ -375,16 +373,12 @@ const useHighlight = ({ } return false; }; - const handleClickBlockList = (event: React.MouseEvent) => { + const handleLongPressLine = (event: React.MouseEvent | React.TouchEvent) => { if (!isEditable) return; - - const isSameSelectedNode = isSingleCharacterSelected(); - - if (isSameSelectedNode) return; + if (isSingleCharacterSelected()) return; const target = event.target as HTMLElement; if (!target.classList.contains(HIGHLIGHT_SPAN_CLASS_NAME)) return; - const answerElement = target.closest(`.${EDITOR_ANSWER_CLASS_NAME}`); if (!answerElement) return; const id = answerElement.getAttribute('data-answer')?.split('-')[0]; @@ -407,10 +401,10 @@ const useHighlight = ({ highlightIndex: Number(highlightIndex), }); - updateRemoverPosition(rect); + updateLongPressHighlightButtonPosition(rect); }; - const removeHighlightByClick = () => { + const removeHighlightByLongPress = async () => { if (!removalTarget) return; const { answerId, lineIndex, highlightIndex } = removalTarget; @@ -430,14 +424,15 @@ const useHighlight = ({ newEditorAnswerMap.set(answerId, { ...targetAnswer, lineList: newLineList }); mutateHighlight(newEditorAnswerMap); + hideLongPressHighlightButton(); }; return { editorAnswerMap, - addHighlight, + addHighlightByDrag, removeHighlightByDrag, - handleClickBlockList, - removeHighlightByClick, + handleLongPressLine, + removeHighlightByLongPress, removalTarget, }; }; diff --git a/frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlightEventListener.ts b/frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlightEventListener.ts new file mode 100644 index 000000000..88b21dc82 --- /dev/null +++ b/frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlightEventListener.ts @@ -0,0 +1,85 @@ +import { useEffect } from 'react'; + +import { HIGHLIGHT__TOGGLE_BUTTON_CLASS_NAME, HIGHLIGHT_REMOVER_CLASS_NAME } from '@/constants'; +import { findSelectionInfo, isTouchDevice, SelectionInfo } from '@/utils'; + +import { getDragHighlightButtonParams } from './useDragHighlightButtonPosition'; + +interface UseHighlightEventListenerProps { + isEditable: boolean; + updateDragHighlightButtonPosition: ({ selectionInfo, isAddingHighlight }: getDragHighlightButtonParams) => void; + hideDragHighlightButton: () => void; + hideLongPressHighlightButton: () => void; + checkHighlight: (info: SelectionInfo) => boolean; +} + +/** + * document에 형광펜 관련 이벤트를 붙이는 훅 + */ +const useHighlightEventListener = ({ + isEditable, + updateDragHighlightButtonPosition, + hideDragHighlightButton, + hideLongPressHighlightButton, + checkHighlight, +}: UseHighlightEventListenerProps) => { + const hideHighlightButton = (e: MouseEvent | TouchEvent) => { + if (!isEditable) return; + + const isInButton = (e.target as HTMLElement).closest(`.${HIGHLIGHT__TOGGLE_BUTTON_CLASS_NAME}`); + const isNotHighlightRemover = (e.target as HTMLElement).closest(`.${HIGHLIGHT_REMOVER_CLASS_NAME}`); + + if (!isInButton) hideDragHighlightButton(); + if (!isNotHighlightRemover) hideLongPressHighlightButton(); + }; + + const showHighlightButton = () => { + if (!isEditable) return; + const selectionInfo = findSelectionInfo(); + if (!selectionInfo) return; + + const isAddingHighlight = checkHighlight(selectionInfo); + updateDragHighlightButtonPosition({ selectionInfo, isAddingHighlight }); + }; + + /** + * document에 형광펜 이벤트 적용 + */ + const addHighlightEvent = () => { + document.addEventListener('mousedown', hideHighlightButton); + document.addEventListener('mouseup', showHighlightButton); + // NOTE: 터치가 가능한 기기에서는 touchstart, touchend 보다 selectionchange를 사용하는 게 오류가 없음 + if (isTouchDevice()) { + document.addEventListener('selectionchange', showHighlightButton); + document.addEventListener('contextmenu', hideContextMenuInTouch); + } + }; + /** + * 터치 브라우저에서, 글자 길게 선택 시 나오는 브라우저 기본 컨텍스트 메뉴 보이지 않게 처리하는 핸들러 + * @param event + */ + const hideContextMenuInTouch = (event: MouseEvent) => { + event.preventDefault(); + }; + /** + * document에 형광펜 이벤트 삭제 + */ + const removeHighlightEvent = () => { + document.removeEventListener('mouseup', showHighlightButton); + document.removeEventListener('mousedown', hideHighlightButton); + if (isTouchDevice()) { + document.removeEventListener('contextmenu', hideContextMenuInTouch); + document.removeEventListener('selectionChange', showHighlightButton); + } + }; + + useEffect(() => { + isEditable ? addHighlightEvent() : removeHighlightEvent(); + + return () => { + removeHighlightEvent(); + }; + }, [isEditable]); +}; + +export default useHighlightEventListener; diff --git a/frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlightRemoverPosition.ts b/frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlightRemoverPosition.ts deleted file mode 100644 index eecfed581..000000000 --- a/frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlightRemoverPosition.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { useLayoutEffect, useState } from 'react'; - -import { GAP_WIDTH_SELECTION_AND_HIGHLIGHT_BUTTON, HIGHLIGHT_BUTTON_SIZE } from '@/constants'; -import { Position } from '@/types'; - -interface UseHighlightRemoverPositionProps { - isEditable: boolean; - editorRef: React.RefObject; -} -const useHighlightRemoverPosition = ({ isEditable, editorRef }: UseHighlightRemoverPositionProps) => { - const [removerPosition, setRemoverPosition] = useState(null); - - const updateRemoverPosition = (rect: DOMRect) => { - const editorRect = editorRef.current?.getClientRects()[0]; - if (!editorRect) return; - const top = rect.bottom - editorRect.top; - const left = rect.right - editorRect.left; - - const buttonWidth = HIGHLIGHT_BUTTON_SIZE.width.basic; - - const isOverEditorArea = editorRect.right < rect.right + buttonWidth; - const topOffsetFromParent = isOverEditorArea ? top + GAP_WIDTH_SELECTION_AND_HIGHLIGHT_BUTTON : top; - const leftOffsetFromParent = isOverEditorArea ? editorRect.width - buttonWidth : left; - - setRemoverPosition({ - top: ` - ${topOffsetFromParent / 10}rem`, - left: `${leftOffsetFromParent / 10}rem`, - }); - }; - - const hideRemover = () => setRemoverPosition(null); - - useLayoutEffect(() => { - if (!isEditable) hideRemover(); - }, [isEditable]); - - return { - removerPosition, - updateRemoverPosition, - hideRemover, - }; -}; - -export default useHighlightRemoverPosition; diff --git a/frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlightToggleButtonPosition.ts b/frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlightToggleButtonPosition.ts deleted file mode 100644 index 806c3285e..000000000 --- a/frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlightToggleButtonPosition.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { useLayoutEffect, useState } from 'react'; - -import { GAP_WIDTH_SELECTION_AND_HIGHLIGHT_BUTTON, HIGHLIGHT_BUTTON_SIZE } from '@/constants'; -import { Position } from '@/types'; -import { EditorSelectionInfo } from '@/utils'; - -interface UseHighlightButtonPositionProps { - isEditable: boolean; - editorRef: React.RefObject; -} - -const useHighlightToggleButtonPosition = ({ isEditable, editorRef }: UseHighlightButtonPositionProps) => { - const [highlightToggleButtonPosition, setHighlightToggleButtonPosition] = useState(null); - - const hideHighlightToggleButton = () => setHighlightToggleButtonPosition(null); - - interface CalculateEndPositionParams { - info: EditorSelectionInfo; - isAddingHighlight: boolean; - } - const calculateEndPosition = ({ info, isAddingHighlight }: CalculateEndPositionParams) => { - const { selection, isForwardDrag, startBlock } = info; - if (!editorRef.current) return; - const range = selection.getRangeAt(0); - const rects = range.getClientRects(); - const editorRect = editorRef.current.getClientRects()[0]; - - if (rects.length === 0) return; - - // 드래그 방향에 따른 마지막 rect의 좌표 정보를 가져옴 (마우스가 놓인 최종 지점) - const lastRect = rects[isForwardDrag ? rects.length - 1 : 0]; - const buttonHight = HIGHLIGHT_BUTTON_SIZE.height; - const { basic: buttonBasicWidth, buttonWidthColor: addButtonWidth } = HIGHLIGHT_BUTTON_SIZE.width; - const buttonWidth = isAddingHighlight ? addButtonWidth : buttonBasicWidth; - - const rectLeft = isForwardDrag ? lastRect.right : lastRect.left; - const left = rectLeft - editorRect.left; - const top = - lastRect.top - - (isForwardDrag ? 0 : startBlock.clientHeight + buttonHight + GAP_WIDTH_SELECTION_AND_HIGHLIGHT_BUTTON) - - editorRect.top + - buttonHight; - - const isOverEditorArea = editorRect.right < rectLeft + buttonWidth; - const leftOffsetFromParent = isOverEditorArea ? editorRect.width - buttonWidth : left; - const topOffsetFromParent = top; - const endPosition: Position = { - left: `${leftOffsetFromParent / 10}rem`, - top: `${topOffsetFromParent / 10}rem`, - }; - - return endPosition; - }; - - const updateHighlightToggleButtonPosition = ({ info, isAddingHighlight }: CalculateEndPositionParams) => { - const endPosition = calculateEndPosition({ info, isAddingHighlight }); - if (!endPosition) return console.error('endPosition을 찾을 수 없어요.'); - - setHighlightToggleButtonPosition(endPosition); - }; - - useLayoutEffect(() => { - if (!isEditable) hideHighlightToggleButton(); - }, [isEditable]); - - useLayoutEffect(() => {}); - - return { - highlightToggleButtonPosition, - hideHighlightToggleButton, - updateHighlightToggleButtonPosition, - }; -}; - -export default useHighlightToggleButtonPosition; diff --git a/frontend/src/components/highlight/components/HighlightEditor/hooks/useLongPress.ts b/frontend/src/components/highlight/components/HighlightEditor/hooks/useLongPress.ts new file mode 100644 index 000000000..782fd68f3 --- /dev/null +++ b/frontend/src/components/highlight/components/HighlightEditor/hooks/useLongPress.ts @@ -0,0 +1,32 @@ +import React, { useState } from 'react'; + +interface UseLongPressProps { + handleLongPress: (event: React.MouseEvent | React.TouchEvent) => void; + longPressDuration?: number; +} + +const useLongPress = ({ handleLongPress, longPressDuration = 500 }: UseLongPressProps) => { + const [pressTimer, setPressTimer] = useState(null); + + const startPressTimer = (event: React.MouseEvent | React.TouchEvent) => { + const timer = setTimeout(() => { + handleLongPress(event); + }, longPressDuration); + setPressTimer(timer); + }; + + const clearPressTimer = () => { + // 사용자가 누르는 동작을 멈추면 타이머를 클리어 + if (pressTimer) { + clearTimeout(pressTimer); + setPressTimer(null); + } + }; + + return { + startPressTimer, + clearPressTimer, + }; +}; + +export default useLongPress; diff --git a/frontend/src/components/highlight/components/HighlightEditor/hooks/useLongPressHighlightButtonPosition.ts b/frontend/src/components/highlight/components/HighlightEditor/hooks/useLongPressHighlightButtonPosition.ts new file mode 100644 index 000000000..6d4b97f26 --- /dev/null +++ b/frontend/src/components/highlight/components/HighlightEditor/hooks/useLongPressHighlightButtonPosition.ts @@ -0,0 +1,50 @@ +import { useLayoutEffect, useState } from 'react'; + +import { GAP_WIDTH_SELECTION_AND_HIGHLIGHT_BUTTON, HIGHLIGHT_BUTTON_SIZE } from '@/constants'; +import { Position } from '@/types'; + +interface UseLongPressHighlightButtonPositionProps { + isEditable: boolean; + editorRef: React.RefObject; +} +const useLongPressHighlightButtonPosition = ({ isEditable, editorRef }: UseLongPressHighlightButtonPositionProps) => { + const [longPressHighlightButtonPosition, setLongPressHighlightButtonPosition] = useState(null); + + const updateLongPressHighlightButtonPosition = (rect: DOMRect) => { + const editorRect = editorRef.current?.getClientRects()[0]; + if (!editorRect) return; + + const top = rect.bottom + GAP_WIDTH_SELECTION_AND_HIGHLIGHT_BUTTON; + const left = rect.left + rect.width / 2 - editorRect.left; + + const buttonTotalHeight = HIGHLIGHT_BUTTON_SIZE.height + HIGHLIGHT_BUTTON_SIZE.shadow; + const isOverflowingVertically = + rect.bottom + GAP_WIDTH_SELECTION_AND_HIGHLIGHT_BUTTON + buttonTotalHeight >= editorRect.bottom; + + const topOffsetFromParent = + (isOverflowingVertically ? rect.top - GAP_WIDTH_SELECTION_AND_HIGHLIGHT_BUTTON - buttonTotalHeight : top) - + editorRect.top; + + const leftOffsetFromParent = left; + + setLongPressHighlightButtonPosition({ + top: ` + ${topOffsetFromParent / 10}rem`, + left: `${leftOffsetFromParent / 10}rem`, + }); + }; + + const hideLongPressHighlightButton = () => setLongPressHighlightButtonPosition(null); + + useLayoutEffect(() => { + if (!isEditable) hideLongPressHighlightButton(); + }, [isEditable]); + + return { + longPressHighlightButtonPosition, + updateLongPressHighlightButtonPosition, + hideLongPressHighlightButton, + }; +}; + +export default useLongPressHighlightButtonPosition; diff --git a/frontend/src/components/highlight/components/HighlightEditor/index.tsx b/frontend/src/components/highlight/components/HighlightEditor/index.tsx index 8007b987d..313e81e6f 100644 --- a/frontend/src/components/highlight/components/HighlightEditor/index.tsx +++ b/frontend/src/components/highlight/components/HighlightEditor/index.tsx @@ -1,26 +1,23 @@ -import { useEffect, useRef, useState } from 'react'; +import { useRef } from 'react'; import GrayHighlighterIcon from '@/assets/grayHighlighter.svg'; import PrimaryHighlighterIcon from '@/assets/primaryHighlighter.svg'; -import { - EDITOR_ANSWER_CLASS_NAME, - EDITOR_LINE_CLASS_NAME, - HIGHLIGHT__TOGGLE_BUTTON_CLASS_NAME, - HIGHLIGHT_REMOVER_CLASS_NAME, -} from '@/constants'; +import { EDITOR_ANSWER_CLASS_NAME, EDITOR_LINE_CLASS_NAME } from '@/constants'; import { ReviewAnswerResponseData } from '@/types'; -import { findSelectionInfo } from '@/utils'; +import DragHighlightButtonContainer from '../DragHighlightButtonContainer'; import EditorLineBlock from '../EditorLineBlock'; import EditSwitchButton from '../EditSwitchButton'; -import HighlightRemoverWrapper from '../HighlightRemoverWrapper'; -import HighlightToggleButtonContainer from '../HighlightToggleButtonContainer'; +import LongPressHighlightButtonWrapper from '../LongPressHighlightButtonWrapper'; import { - useHighlightToggleButtonPosition, + useDragHighlightButtonPosition, useHighlight, useCheckHighlight, - useHighlightRemoverPosition, + useLongPressHighlightButtonPosition, + useLongPress, + useEditableState, + useHighlightEventListener, } from './hooks'; import * as S from './style'; @@ -42,68 +39,50 @@ export interface HighlightEditorProps { const HighlightEditor = ({ questionId, answerList, handleErrorModal }: HighlightEditorProps) => { const editorRef = useRef(null); - const [isEditable, setIsEditable] = useState(false); - const { isAddingHighlight, checkHighlight } = useCheckHighlight(); - const handleEditToggleButton = () => { - setIsEditable((prev) => !prev); - }; + const { isEditable, handleEditToggleButton } = useEditableState(); + + const { isAddingHighlight, checkHighlight } = useCheckHighlight(); - const { highlightToggleButtonPosition, hideHighlightToggleButton, updateHighlightToggleButtonPosition } = - useHighlightToggleButtonPosition({ + const { longPressHighlightButtonPosition, hideLongPressHighlightButton, updateLongPressHighlightButtonPosition } = + useLongPressHighlightButtonPosition({ isEditable, editorRef, }); - const { removerPosition, hideRemover, updateRemoverPosition } = useHighlightRemoverPosition({ - isEditable, - editorRef, - }); + const { dragHighlightButtonPosition, hideDragHighlightButton, updateDragHighlightButtonPosition } = + useDragHighlightButtonPosition({ + isEditable, + editorRef, + hideLongPressHighlightButton, + }); const { editorAnswerMap, - addHighlight, + addHighlightByDrag, removeHighlightByDrag, - handleClickBlockList, - removeHighlightByClick, + handleLongPressLine, + removeHighlightByLongPress, removalTarget, } = useHighlight({ questionId, answerList, isEditable, - hideHighlightToggleButton, - hideRemover, - updateRemoverPosition, + hideDragHighlightButton, + hideLongPressHighlightButton, + updateLongPressHighlightButtonPosition, handleErrorModal, }); - const handleMouseDown = (e: MouseEvent) => { - if (!isEditable) return; - - const isInButton = (e.target as HTMLElement).closest(`.${HIGHLIGHT__TOGGLE_BUTTON_CLASS_NAME}`); - const isNotHighlightRemover = (e.target as HTMLElement).closest(`.${HIGHLIGHT_REMOVER_CLASS_NAME}`); - - if (!isInButton) hideHighlightToggleButton(); - if (!isNotHighlightRemover) hideRemover(); - }; - - const handleMouseUp = () => { - if (!isEditable) return; - const info = findSelectionInfo(); - if (!info) return; + const { startPressTimer, clearPressTimer } = useLongPress({ handleLongPress: handleLongPressLine }); - const isAddingHighlight = checkHighlight(info); - updateHighlightToggleButtonPosition({ info, isAddingHighlight }); - }; - - useEffect(() => { - document.addEventListener('mouseup', handleMouseUp); - document.addEventListener('mousedown', handleMouseDown); - return () => { - document.removeEventListener('mouseup', handleMouseUp); - document.removeEventListener('mousedown', handleMouseDown); - }; - }, [isEditable]); + useHighlightEventListener({ + isEditable, + updateDragHighlightButtonPosition, + hideDragHighlightButton, + hideLongPressHighlightButton, + checkHighlight, + }); return ( @@ -120,7 +99,10 @@ const HighlightEditor = ({ questionId, answerList, handleErrorModal }: Highlight className={EDITOR_ANSWER_CLASS_NAME} key={answerId} data-answer={`${answerId}-${answerIndex}`} - onClick={handleClickBlockList} + onMouseDown={startPressTimer} + onMouseUp={clearPressTimer} + onMouseMove={clearPressTimer} + onTouchMove={handleLongPressLine} > {lineList.map((line, index) => ( @@ -128,16 +110,19 @@ const HighlightEditor = ({ questionId, answerList, handleErrorModal }: Highlight ))} - {isEditable && highlightToggleButtonPosition && ( - )} - {isEditable && removalTarget && removerPosition && ( - + {isEditable && removalTarget && longPressHighlightButtonPosition && ( + )} ); diff --git a/frontend/src/components/highlight/components/HighlightRemoverWrapper/index.tsx b/frontend/src/components/highlight/components/HighlightRemoverWrapper/index.tsx deleted file mode 100644 index 42a38f87c..000000000 --- a/frontend/src/components/highlight/components/HighlightRemoverWrapper/index.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { Position } from '@/types'; - -import HighlightButton from '../HighlightButton'; - -interface HighlightRemoverWrapperProps { - buttonPosition: Position; - removeHighlightByClick: () => void; -} -/** - * 하이라이트 된 span 태그 클릭 시, 해당 하이라이트를 삭제할 수 있는 버튼을 띄우는 컴포넌트 - */ -const HighlightRemoverWrapper = ({ buttonPosition, removeHighlightByClick }: HighlightRemoverWrapperProps) => { - return ; -}; - -export default HighlightRemoverWrapper; diff --git a/frontend/src/components/highlight/components/LongPressHighlightButtonWrapper/index.tsx b/frontend/src/components/highlight/components/LongPressHighlightButtonWrapper/index.tsx new file mode 100644 index 000000000..c30d447c0 --- /dev/null +++ b/frontend/src/components/highlight/components/LongPressHighlightButtonWrapper/index.tsx @@ -0,0 +1,24 @@ +import { Position } from '@/types'; + +import HighlightButton from '../HighlightButton'; + +interface LongPressHighlightButtonWrapperProps { + buttonPosition: Position; + removeHighlightByLongPress: () => void; +} +/** + * 하이라이트 된 span 태그 클릭 시, 해당 하이라이트를 삭제할 수 있는 버튼을 띄우는 컴포넌트 + */ +const LongPressHighlightButtonWrapper = ({ + buttonPosition, + removeHighlightByLongPress, +}: LongPressHighlightButtonWrapperProps) => { + return ( + + ); +}; + +export default LongPressHighlightButtonWrapper; diff --git a/frontend/src/components/highlight/components/Syntax/style.ts b/frontend/src/components/highlight/components/Syntax/style.ts index e028b9bb7..70de7fb2e 100644 --- a/frontend/src/components/highlight/components/Syntax/style.ts +++ b/frontend/src/components/highlight/components/Syntax/style.ts @@ -4,7 +4,8 @@ interface SyntaxProps { $isHighlight: boolean; } export const Syntax = styled.span` + cursor: ${({$isHighlight})=> $isHighlight? 'pointer' :'auto'}; line-height: 1.5; color: ${(props) => (props.$isHighlight ? props.theme.colors.white : 'inherit')}; - background-color: ${(props) => (props.$isHighlight ? props.theme.colors.primary : 'transparent')}; + background-color: ${(props) => (props.$isHighlight ? props.theme.colors.primary : 'transparent')} `; diff --git a/frontend/src/constants/highlight.ts b/frontend/src/constants/highlight.ts index 02549fcc0..c2497f0ff 100644 --- a/frontend/src/constants/highlight.ts +++ b/frontend/src/constants/highlight.ts @@ -11,5 +11,6 @@ export const HIGHLIGHT_BUTTON_SIZE = { buttonWidthColor: 52, basic: 31, }, + shadow: 10, }; -export const GAP_WIDTH_SELECTION_AND_HIGHLIGHT_BUTTON = 10; +export const GAP_WIDTH_SELECTION_AND_HIGHLIGHT_BUTTON = 5; diff --git a/frontend/src/constants/index.ts b/frontend/src/constants/index.ts index 380620f06..2d1492954 100644 --- a/frontend/src/constants/index.ts +++ b/frontend/src/constants/index.ts @@ -5,3 +5,4 @@ export * from './queryKey'; export * from './routerParam'; export * from './highlight'; export * from './screenReader'; +export * from './sessionStorageKey'; diff --git a/frontend/src/constants/sessionStorageKey.ts b/frontend/src/constants/sessionStorageKey.ts new file mode 100644 index 000000000..c3d462ca4 --- /dev/null +++ b/frontend/src/constants/sessionStorageKey.ts @@ -0,0 +1,3 @@ +export const SESSION_STORAGE_KEY = { + isHighlightEditable: 'isHighlightEditable', +}; diff --git a/frontend/src/mocks/handlers/highlight.ts b/frontend/src/mocks/handlers/highlight.ts index 1171c3c29..a2a46d426 100644 --- a/frontend/src/mocks/handlers/highlight.ts +++ b/frontend/src/mocks/handlers/highlight.ts @@ -6,7 +6,7 @@ import { authorizeWithCookie } from './cookies'; const postMockHighlight = () => http.post(endPoint.postingHighlight, ({ cookies }) => { - return authorizeWithCookie(cookies, () => HttpResponse.json({ status: 200 })); + return authorizeWithCookie(cookies, () => HttpResponse.json({ error: 'error' }, { status: 200 })); }); const highlightHandler = [postMockHighlight()]; diff --git a/frontend/src/utils/highlight/highlighList.ts b/frontend/src/utils/highlight/highlighList.ts index bd3ce3d1c..b20dd63c4 100644 --- a/frontend/src/utils/highlight/highlighList.ts +++ b/frontend/src/utils/highlight/highlighList.ts @@ -7,7 +7,7 @@ interface CreateHighlightBinaryArrayParams { /** * 하이라이트 적용 여부를 이진법에 따라 표시하는 배열을 생성하는 함수 * @param list 배열에 표시할 하이라이트 배열 - * @param arrayLength 이진법 배열의 length이자 하이라이트 적용 대상인 block의 글자 수 + * @param arrayLength 이진법 배열의 length이자 하이라이트 적용 대상인 line의 글자 수 */ const createHighlightBinaryArray = ({ arrayLength, list }: CreateHighlightBinaryArrayParams) => { const array = '0'.repeat(arrayLength).split(''); @@ -127,7 +127,7 @@ const getHighlightListAfterFullyRemoval = ({ /*하이라이트 삭제 함수*/ export const getRemovedHighlightList = (params: GetRemovedHighlightListParams) => { const { highlightList, startIndex, endIndex } = params; - // 한 글자만 하이라이트된 것을 삭제하는 겨우 + // 한 글자만 하이라이트된 것을 삭제하는 경우 const isRemoveSingleHighlight = highlightList.find((h) => h.endIndex == endIndex && h.startIndex === startIndex); if (isRemoveSingleHighlight) diff --git a/frontend/src/utils/highlight/selection.ts b/frontend/src/utils/highlight/selection.ts index 758056eb1..cdcd016d5 100644 --- a/frontend/src/utils/highlight/selection.ts +++ b/frontend/src/utils/highlight/selection.ts @@ -7,9 +7,9 @@ interface GetSelectionOffsetInBlockParams { blockElement: Element; } /* - *선택된 텍스트의 block 기준 offset을 계산하는 함수 + *선택된 텍스트의 Line 기준 offset을 계산하는 함수 */ -export const calculateOffsetInBlock = ({ +export const calculateOffsetInLine = ({ selectionTargetNode, selectionTargetOffset, blockElement, @@ -49,14 +49,15 @@ interface BlockData { index: number; } interface GetAnswerInfoParams { - anchorBlockData: BlockData; - focusBlockData: BlockData; + anchorLineData: BlockData; + focusLineData: BlockData; anchorOffset: number; focusOffset: number; } -export const getAnswerInfo = ({ anchorBlockData, focusBlockData, anchorOffset, focusOffset }: GetAnswerInfoParams) => { - const anchorAnswerElement = anchorBlockData.block.closest(`.${EDITOR_ANSWER_CLASS_NAME}`); - const focusAnswerElement = focusBlockData.block.closest(`.${EDITOR_ANSWER_CLASS_NAME}`); + +export const getAnswerInfo = ({ anchorLineData, focusLineData, anchorOffset, focusOffset }: GetAnswerInfoParams) => { + const anchorAnswerElement = anchorLineData.block.closest(`.${EDITOR_ANSWER_CLASS_NAME}`); + const focusAnswerElement = focusLineData.block.closest(`.${EDITOR_ANSWER_CLASS_NAME}`); if (!anchorAnswerElement || !focusAnswerElement) return; @@ -71,12 +72,12 @@ export const getAnswerInfo = ({ anchorBlockData, focusBlockData, anchorOffset, f const isForwardDragAnswer = sortedAnswerData[0].id === anchorAnswerData.id; const startAnswer = isForwardDragAnswer - ? { ...anchorAnswerData, lineIndex: Number(anchorBlockData.index), offset: anchorOffset } - : { ...focusAnswerData, lineIndex: Number(focusBlockData.index), offset: focusOffset }; + ? { ...anchorAnswerData, lineIndex: Number(anchorLineData.index), offset: anchorOffset } + : { ...focusAnswerData, lineIndex: Number(focusLineData.index), offset: focusOffset }; const endAnswer = isForwardDragAnswer - ? { ...focusAnswerData, lineIndex: Number(focusBlockData.index), offset: focusOffset - 1 } - : { ...anchorAnswerData, lineIndex: Number(anchorBlockData.index), offset: anchorOffset - 1 }; + ? { ...focusAnswerData, lineIndex: Number(focusLineData.index), offset: focusOffset - 1 } + : { ...anchorAnswerData, lineIndex: Number(anchorLineData.index), offset: anchorOffset - 1 }; return { isSameAnswer, @@ -87,71 +88,71 @@ export const getAnswerInfo = ({ anchorBlockData, focusBlockData, anchorOffset, f }; /** - * anchorNode, focusNode가 있는 block 정보를 찾는 함수 + * anchorNode, focusNode가 있는 element(Line) 정보를 찾는 함수 * @param selection * @returns */ -export const findSelectedElementInfo = (selection: Selection) => { +export const findSelectedLineInfo = (selection: Selection) => { const { anchorNode, focusNode, anchorOffset, focusOffset } = selection; - const anchorBlock = anchorNode?.parentElement?.closest(`.${EDITOR_LINE_CLASS_NAME}`); - const focusBlock = focusNode?.parentElement?.closest(`.${EDITOR_LINE_CLASS_NAME}`); + const anchorLine = anchorNode?.parentElement?.closest(`.${EDITOR_LINE_CLASS_NAME}`); + const focusLine = focusNode?.parentElement?.closest(`.${EDITOR_LINE_CLASS_NAME}`); - if (!anchorBlock || !focusBlock) return; + if (!anchorLine || !focusLine) return; - const anchorBlockIndex = Number(anchorBlock.getAttribute('data-index') || '-1'); - const focusBlockIndex = Number(focusBlock.getAttribute('data-index') || '-1'); + const anchorLineIndex = Number(anchorLine.getAttribute('data-index') || '-1'); + const focusLineIndex = Number(focusLine.getAttribute('data-index') || '-1'); const answerInfo = getAnswerInfo({ - anchorBlockData: { block: anchorBlock, index: anchorBlockIndex }, - focusBlockData: { block: focusBlock, index: focusBlockIndex }, + anchorLineData: { block: anchorLine, index: anchorLineIndex }, + focusLineData: { block: focusLine, index: focusLineIndex }, anchorOffset, focusOffset, }); return { - anchorBlock, - anchorBlockIndex, - focusBlock, - focusBlockIndex, + anchorLine, + anchorLineIndex, + focusLine, + focusLineIndex, ...answerInfo, }; }; -export type SelectedBlockInfo = Exclude, undefined>; +export type SelectedLineInfo = Exclude, undefined>; -export const calculateStartAndEndBlock = ({ - anchorBlock, - anchorBlockIndex, - focusBlock, - focusBlockIndex, -}: SelectedBlockInfo) => { - const startBlockIndex = Math.min(anchorBlockIndex, focusBlockIndex); - const endBlockIndex = Math.max(anchorBlockIndex, focusBlockIndex); - const startBlock = startBlockIndex === anchorBlockIndex ? anchorBlock : focusBlock; - const endBlock = startBlockIndex === anchorBlockIndex ? focusBlock : anchorBlock; +export const calculateStartAndEndLine = ({ + anchorLine, + anchorLineIndex, + focusLine, + focusLineIndex, +}: SelectedLineInfo) => { + const startLineIndex = Math.min(anchorLineIndex, focusLineIndex); + const endLineIndex = Math.max(anchorLineIndex, focusLineIndex); + const startLine = startLineIndex === anchorLineIndex ? anchorLine : focusLine; + const endLine = startLineIndex === anchorLineIndex ? focusLine : anchorLine; return { - startBlock, - startBlockIndex, - endBlock, - endBlockIndex, + startLine, + startLineIndex, + endLine, + endLineIndex, }; }; interface CalculateDragDirectionParams { selection: Selection; - startBlockIndex: number; - endBlockIndex: number; - anchorBlockIndex: number; + startLineIndex: number; + endLineIndex: number; + anchorLineIndex: number; isSameAnswer: boolean; isForwardDragAnswer: boolean; } export const calculateDragDirection = ({ selection, - startBlockIndex, - endBlockIndex, - anchorBlockIndex, + startLineIndex, + endLineIndex, + anchorLineIndex, isSameAnswer, isForwardDragAnswer, }: CalculateDragDirectionParams) => { @@ -160,7 +161,7 @@ export const calculateDragDirection = ({ if (isSameAnswer) { const isForwardDrag = - startBlockIndex === endBlockIndex ? minOffset === anchorOffset : startBlockIndex === anchorBlockIndex; + startLineIndex === endLineIndex ? minOffset === anchorOffset : startLineIndex === anchorLineIndex; return isForwardDrag; } @@ -176,65 +177,65 @@ export const findSelectionInfo = () => { const selection = document.getSelection(); if (!selection || selection.isCollapsed) return; - const selectedElementInfo = findSelectedElementInfo(selection); + const selectedElementInfo = findSelectedLineInfo(selection); if (!selectedElementInfo) return; const { isSameAnswer } = selectedElementInfo; - const { startBlock, startBlockIndex, endBlock, endBlockIndex } = calculateStartAndEndBlock(selectedElementInfo); + const { startLine, startLineIndex, endLine, endLineIndex } = calculateStartAndEndLine(selectedElementInfo); const isForwardDrag = calculateDragDirection({ selection, - startBlockIndex, - endBlockIndex, - anchorBlockIndex: selectedElementInfo.anchorBlockIndex, + startLineIndex, + endLineIndex, + anchorLineIndex: selectedElementInfo.anchorLineIndex, isSameAnswer: !!isSameAnswer, isForwardDragAnswer: !!selectedElementInfo.isForwardDragAnswer, }); - const isOnlyOneSelectedBlock = startBlockIndex === endBlockIndex; + const isOnlyOneSelectedBlock = startLineIndex === endLineIndex; return { selection, - startBlock, - endBlock, - startBlockIndex, - endBlockIndex, + startLine, + endLine, + startLineIndex, + endLineIndex, isForwardDrag, isOnlyOneSelectedBlock, ...selectedElementInfo, }; }; -export type EditorSelectionInfo = Exclude, undefined>; +export type SelectionInfo = Exclude, undefined>; -export const getStartBlockOffset = (infoForOffset: EditorSelectionInfo, block: EditorLine) => { - const { isForwardDrag, startBlock, selection, isOnlyOneSelectedBlock } = infoForOffset; +export const getStartLineOffset = (infoForOffset: SelectionInfo, block: EditorLine) => { + const { isForwardDrag, startLine, selection, isOnlyOneSelectedBlock } = infoForOffset; const { anchorNode, focusNode, anchorOffset, focusOffset } = selection; - const startIndex = calculateOffsetInBlock({ + const startIndex = calculateOffsetInLine({ selectionTargetNode: isForwardDrag ? anchorNode : focusNode, selectionTargetOffset: isForwardDrag ? anchorOffset : focusOffset, - blockElement: startBlock, + blockElement: startLine, }); // NOTE: endIndex에 -1하는 이유 : 끝나는 포커스위치의 offset이 글자 index보다 1큼 const endIndex = isOnlyOneSelectedBlock - ? calculateOffsetInBlock({ + ? calculateOffsetInLine({ selectionTargetNode: isForwardDrag ? focusNode : anchorNode, selectionTargetOffset: isForwardDrag ? focusOffset - 1 : anchorOffset - 1, - blockElement: startBlock, + blockElement: startLine, }) : block.text.length; return { startIndex, endIndex }; }; -export const getEndBlockOffset = (infoForOffset: EditorSelectionInfo) => { - const { isForwardDrag, endBlock, selection } = infoForOffset; +export const getEndLineOffset = (infoForOffset: SelectionInfo) => { + const { isForwardDrag, endLine, selection } = infoForOffset; const { anchorNode, anchorOffset, focusNode, focusOffset } = selection; - const endIndex = calculateOffsetInBlock({ + const endIndex = calculateOffsetInLine({ selectionTargetNode: isForwardDrag ? focusNode : anchorNode, selectionTargetOffset: isForwardDrag ? focusOffset - 1 : anchorOffset - 1, - blockElement: endBlock, + blockElement: endLine, }); return endIndex; diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts index 88a50dfbc..8f6ac7b77 100644 --- a/frontend/src/utils/index.ts +++ b/frontend/src/utils/index.ts @@ -1,10 +1,11 @@ -export * from './date'; export { default as isExistentElement } from './isExistentElement'; export { default as scrollToTop } from './scrollToTop'; export { default as debounce } from './debounce'; export { default as hasFinalConsonant } from './hasFinalConsonant'; export { default as substituteString } from './substituteString'; export { default as calculateParticle } from './calculateParticle'; +export { default as isTouchDevice } from './touchDevice'; +export * from './date'; export * from './media'; export * from './highlight/index'; export * from './testUtils'; diff --git a/frontend/src/utils/touchDevice.ts b/frontend/src/utils/touchDevice.ts new file mode 100644 index 000000000..3f672bd18 --- /dev/null +++ b/frontend/src/utils/touchDevice.ts @@ -0,0 +1,8 @@ +/** + * 터치 가능 장치인지 확인 + */ +const isTouchDevice = () => { + return 'ontouchstart' in window || navigator.maxTouchPoints > 0 || window.matchMedia('(pointer: coarse)').matches; +}; + +export default isTouchDevice; From 834feffd994a737b4cb38528cf58747543ae71ff Mon Sep 17 00:00:00 2001 From: Donghoon Lee Date: Sun, 20 Oct 2024 04:22:11 +0900 Subject: [PATCH 30/49] =?UTF-8?q?[BE]=20feat:=20=EB=AA=A8=EC=95=84?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=20=EC=8B=9C=20=ED=95=98=EC=9D=B4=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EC=A0=95=EB=B3=B4=20=ED=8F=AC=ED=95=A8?= =?UTF-8?q?=ED=95=B4=EC=84=9C=20=EC=9D=91=EB=8B=B5=20(#876)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 하이라이트 정보 함께 조회 * fix: 테스트 컴파일 에러 해결 * refactor: 하이라이트 순서대로 정렬 * chore: remove unused import * test: 오름차순 테스트 * refactor: `answerIds` 중복 제거 * refactor: groupingby 다시 합쳐두기 * style: 클래스 개행 --- .../repository/HighlightRepository.java | 9 ++++ .../service/ReviewGatheredLookupService.java | 13 ++++- .../dto/response/gathered/RangeResponse.java | 6 +++ .../service/mapper/ReviewGatherMapper.java | 39 ++++++++++++--- .../repository/HighlightRepositoryTest.java | 47 +++++++++++++++++++ .../mapper/ReviewGatherMapperTest.java | 16 +++++-- 6 files changed, 118 insertions(+), 12 deletions(-) create mode 100644 backend/src/test/java/reviewme/highlight/repository/HighlightRepositoryTest.java diff --git a/backend/src/main/java/reviewme/highlight/repository/HighlightRepository.java b/backend/src/main/java/reviewme/highlight/repository/HighlightRepository.java index c7e3b5adf..74760e09c 100644 --- a/backend/src/main/java/reviewme/highlight/repository/HighlightRepository.java +++ b/backend/src/main/java/reviewme/highlight/repository/HighlightRepository.java @@ -1,6 +1,7 @@ package reviewme.highlight.repository; import java.util.Collection; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -14,4 +15,12 @@ public interface HighlightRepository extends JpaRepository { WHERE h.answerId IN :answerIds """) void deleteAllByAnswerIds(Collection answerIds); + + @Query(""" + SELECT h + FROM Highlight h + WHERE h.answerId IN :answerIds + ORDER BY h.lineIndex, h.highlightRange.startIndex ASC + """) + List findAllByAnswerIdsOrderedAsc(Collection answerIds); } diff --git a/backend/src/main/java/reviewme/review/service/ReviewGatheredLookupService.java b/backend/src/main/java/reviewme/review/service/ReviewGatheredLookupService.java index a03f2aca2..703348c9e 100644 --- a/backend/src/main/java/reviewme/review/service/ReviewGatheredLookupService.java +++ b/backend/src/main/java/reviewme/review/service/ReviewGatheredLookupService.java @@ -7,6 +7,8 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import reviewme.highlight.domain.Highlight; +import reviewme.highlight.repository.HighlightRepository; import reviewme.question.domain.Question; import reviewme.question.repository.QuestionRepository; import reviewme.review.domain.Answer; @@ -27,6 +29,7 @@ public class ReviewGatheredLookupService { private final QuestionRepository questionRepository; private final AnswerRepository answerRepository; private final SectionRepository sectionRepository; + private final HighlightRepository highlightRepository; private final ReviewGatherMapper reviewGatherMapper; @@ -35,7 +38,15 @@ public ReviewsGatheredBySectionResponse getReceivedReviewsBySectionId(ReviewGrou Section section = getSectionOrThrow(sectionId, reviewGroup); Map> questionAnswers = getQuestionAnswers(section, reviewGroup); - return reviewGatherMapper.mapToReviewsGatheredBySection(questionAnswers); + List answerIds = questionAnswers.values() + .stream() + .flatMap(List::stream) + .map(Answer::getId) + .distinct() + .toList(); + List highlights = highlightRepository.findAllByAnswerIdsOrderedAsc(answerIds); + + return reviewGatherMapper.mapToReviewsGatheredBySection(questionAnswers, highlights); } private Section getSectionOrThrow(long sectionId, ReviewGroup reviewGroup) { diff --git a/backend/src/main/java/reviewme/review/service/dto/response/gathered/RangeResponse.java b/backend/src/main/java/reviewme/review/service/dto/response/gathered/RangeResponse.java index 046b02f73..f937f9236 100644 --- a/backend/src/main/java/reviewme/review/service/dto/response/gathered/RangeResponse.java +++ b/backend/src/main/java/reviewme/review/service/dto/response/gathered/RangeResponse.java @@ -1,7 +1,13 @@ package reviewme.review.service.dto.response.gathered; +import reviewme.highlight.domain.HighlightRange; + public record RangeResponse( long startIndex, long endIndex ) { + + public static RangeResponse from(HighlightRange range) { + return new RangeResponse(range.getStartIndex(), range.getEndIndex()); + } } diff --git a/backend/src/main/java/reviewme/review/service/mapper/ReviewGatherMapper.java b/backend/src/main/java/reviewme/review/service/mapper/ReviewGatherMapper.java index a21a6da6f..2a1f4e135 100644 --- a/backend/src/main/java/reviewme/review/service/mapper/ReviewGatherMapper.java +++ b/backend/src/main/java/reviewme/review/service/mapper/ReviewGatherMapper.java @@ -6,6 +6,7 @@ import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import reviewme.highlight.domain.Highlight; import reviewme.question.domain.OptionItem; import reviewme.question.domain.Question; import reviewme.question.repository.QuestionRepository; @@ -13,6 +14,8 @@ import reviewme.review.domain.CheckboxAnswer; import reviewme.review.domain.CheckboxAnswerSelectedOption; import reviewme.review.domain.TextAnswer; +import reviewme.review.service.dto.response.gathered.HighlightResponse; +import reviewme.review.service.dto.response.gathered.RangeResponse; import reviewme.review.service.dto.response.gathered.ReviewsGatheredByQuestionResponse; import reviewme.review.service.dto.response.gathered.ReviewsGatheredBySectionResponse; import reviewme.review.service.dto.response.gathered.SimpleQuestionResponse; @@ -26,32 +29,56 @@ public class ReviewGatherMapper { private final QuestionRepository questionRepository; - public ReviewsGatheredBySectionResponse mapToReviewsGatheredBySection(Map> questionAnswers) { + public ReviewsGatheredBySectionResponse mapToReviewsGatheredBySection(Map> questionAnswers, + List highlights) { List reviews = questionAnswers.entrySet() .stream() - .map(entry -> mapToReviewsGatheredByQuestion(entry.getKey(), entry.getValue())) + .map(entry -> mapToReviewsGatheredByQuestion(entry.getKey(), entry.getValue(), highlights)) .toList(); return new ReviewsGatheredBySectionResponse(reviews); } - private ReviewsGatheredByQuestionResponse mapToReviewsGatheredByQuestion(Question question, List answers) { + private ReviewsGatheredByQuestionResponse mapToReviewsGatheredByQuestion(Question question, List answers, + List highlights) { return new ReviewsGatheredByQuestionResponse( new SimpleQuestionResponse(question.getId(), question.getContent(), question.getQuestionType()), - mapToTextResponse(question, answers), + mapToTextResponse(question, answers, highlights), mapToVoteResponse(question, answers) ); } @Nullable - private List mapToTextResponse(Question question, List answers) { + private List mapToTextResponse(Question question, List answers, + List highlights) { if (question.isSelectable()) { return null; } + Map> answerIdHighlights = highlights.stream() + .collect(Collectors.groupingBy(Highlight::getAnswerId)); List textAnswers = castAllOrThrow(answers, TextAnswer.class); return textAnswers.stream() - .map(textAnswer -> new TextResponse(textAnswer.getId(), textAnswer.getContent(), List.of())) + .map(textAnswer -> new TextResponse( + textAnswer.getId(), textAnswer.getContent(), + mapToHighlightResponse(answerIdHighlights.getOrDefault(textAnswer.getId(), List.of()))) + ).toList(); + } + + private List mapToHighlightResponse(List highlights) { + // Line index를 기준으로 묶되, 묶은 것들은 mapping 함수를 통해 List로 변환 + Map> lineIndexRangeResponses = highlights.stream() + .collect(Collectors.groupingBy( + Highlight::getLineIndex, + Collectors.mapping( + highlight -> RangeResponse.from(highlight.getHighlightRange()), + Collectors.toList() + ) + )); + + return lineIndexRangeResponses.entrySet() + .stream() + .map(entry -> new HighlightResponse(entry.getKey(), entry.getValue())) .toList(); } diff --git a/backend/src/test/java/reviewme/highlight/repository/HighlightRepositoryTest.java b/backend/src/test/java/reviewme/highlight/repository/HighlightRepositoryTest.java new file mode 100644 index 000000000..40b584f47 --- /dev/null +++ b/backend/src/test/java/reviewme/highlight/repository/HighlightRepositoryTest.java @@ -0,0 +1,47 @@ +package reviewme.highlight.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import reviewme.highlight.domain.Highlight; +import reviewme.highlight.domain.HighlightRange; + +@DataJpaTest +class HighlightRepositoryTest { + + @Autowired + private HighlightRepository highlightRepository; + + @Test + void 하이라이트를_줄번호_시작_인덱스_순서대로_정렬해서_가져온다() { + // given + highlightRepository.saveAll( + List.of( + new Highlight(1L, 1, new HighlightRange(1, 2)), + new Highlight(1L, 2, new HighlightRange(6, 7)), + new Highlight(1L, 2, new HighlightRange(2, 3)), + new Highlight(1L, 3, new HighlightRange(3, 4)), + new Highlight(1L, 1, new HighlightRange(4, 5)), + new Highlight(2L, 3, new HighlightRange(7, 8)) + ) + ); + // 1: (1, 2), (4, 5) 2: (2, 3), (6, 7) 3: (3, 4) -> 1 4 2 6 3 + + // when + List actual = highlightRepository.findAllByAnswerIdsOrderedAsc(List.of(1L)); + + // then + assertAll( + () -> assertThat(actual).extracting(Highlight::getLineIndex) + .containsExactly(1, 1, 2, 2, 3), + () -> assertThat(actual) + .extracting(Highlight::getHighlightRange) + .extracting(HighlightRange::getStartIndex) + .containsExactly(1, 4, 2, 6, 3) + ); + } +} diff --git a/backend/src/test/java/reviewme/review/service/mapper/ReviewGatherMapperTest.java b/backend/src/test/java/reviewme/review/service/mapper/ReviewGatherMapperTest.java index fdac6e0c3..1e411f13e 100644 --- a/backend/src/test/java/reviewme/review/service/mapper/ReviewGatherMapperTest.java +++ b/backend/src/test/java/reviewme/review/service/mapper/ReviewGatherMapperTest.java @@ -69,8 +69,10 @@ class ReviewGatherMapperTest { // when ReviewsGatheredBySectionResponse actual = reviewGatherMapper.mapToReviewsGatheredBySection(Map.of( - question1, List.of(textAnswer1, textAnswer2), - question2, List.of(checkboxAnswer))); + question1, List.of(textAnswer1, textAnswer2), + question2, List.of(checkboxAnswer)), + List.of() + ); // then assertAll( @@ -90,9 +92,13 @@ class ReviewGatherMapperTest { Question question2 = questionRepository.save(서술형_옵션_질문(2)); // when - ReviewsGatheredBySectionResponse actual = reviewGatherMapper.mapToReviewsGatheredBySection(Map.of( - question1, List.of(), - question2, List.of())); + ReviewsGatheredBySectionResponse actual = reviewGatherMapper.mapToReviewsGatheredBySection( + Map.of( + question1, List.of(), + question2, List.of() + ), + List.of() + ); // then assertAll( From 36df03d87c12ece5a9b0a73b3e4f2438b3cdb298 Mon Sep 17 00:00:00 2001 From: Yeongseo Na Date: Mon, 21 Oct 2024 15:59:40 +0900 Subject: [PATCH 31/49] =?UTF-8?q?[BE]=20chore:=20=EB=AC=B4=EC=A4=91?= =?UTF-8?q?=EB=8B=A8=20=EB=B0=B0=ED=8F=AC=20=EA=B0=9C=EB=B0=9C=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=EC=97=90=20=EC=A0=81=EC=9A=A9=ED=95=98=EB=8A=94=20cd?= =?UTF-8?q?=20=EC=9E=91=EC=84=B1=20(#886)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: 무중단 배포 설정 * chore: dev 서버 무중단 배포 cd 작성 * chore: cd 이름 변경 --- .../zero-downtime-deploy-test-cd.yml | 82 +++++++++++++++++++ backend/src/main/resources/ports.yml | 6 ++ 2 files changed, 88 insertions(+) create mode 100644 .github/workflows/zero-downtime-deploy-test-cd.yml create mode 100644 backend/src/main/resources/ports.yml diff --git a/.github/workflows/zero-downtime-deploy-test-cd.yml b/.github/workflows/zero-downtime-deploy-test-cd.yml new file mode 100644 index 000000000..93b8ce9c0 --- /dev/null +++ b/.github/workflows/zero-downtime-deploy-test-cd.yml @@ -0,0 +1,82 @@ +name: "[test] Zero Downtime Deploy Test CD" + +on: + workflow_dispatch: + +env: + APPLICATION_DIRECTORY: /home/ubuntu/review-me + +jobs: + build: + name: Build Dockerfile and push to DockerHub + runs-on: ubuntu-latest + + steps: + - name: Checkout to current repository + uses: actions/checkout@v4 + + - name: Setup JDK Corretto using cached gradle dependencies + uses: actions/setup-java@v4 + with: + distribution: 'corretto' + java-version: 17 + cache: 'gradle' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + with: + gradle-version: 8.8 + + - name: Build and test with gradle + run: | + cd ./backend + ./gradlew clean bootJar + + - name: Login to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_ID }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: ./backend + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ secrets.DOCKERHUB_ID }}/review-me-app:develop + + deploy: + name: Deploy via self-hosted runner + needs: build + runs-on: [self-hosted, dev] + + steps: + - name: Checkout to secret repository + uses: actions/checkout@v4 + with: + repository: ${{ secrets.PRIVATE_REPOSITORY_URL }} + token: ${{ secrets.PRIVATE_REPOSITORY_TOKEN }} + + - name: Move application-related files to local + run: | + mkdir -p ${{ env.APPLICATION_DIRECTORY }}/app + mv ./app/* ./app/.* ${{ env.APPLICATION_DIRECTORY }}/app + + - name: Login to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_ID }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Deploy new version # 변경 부분 + env: + PROFILE_VAR: "dev" + run: | + chmod +x ./deploy.sh + sudo ./deploy.sh + + working-directory: ${{ env.APPLICATION_DIRECTORY }}/app diff --git a/backend/src/main/resources/ports.yml b/backend/src/main/resources/ports.yml new file mode 100644 index 000000000..8b8093829 --- /dev/null +++ b/backend/src/main/resources/ports.yml @@ -0,0 +1,6 @@ +server: + port: ${SERVER_PORT} + +management: + server: + port: ${ACTUATOR_PORT} From f0acaeab225368b9b044bc937a6735909e5de04a Mon Sep 17 00:00:00 2001 From: Yeongseo Na Date: Mon, 21 Oct 2024 16:21:05 +0900 Subject: [PATCH 32/49] =?UTF-8?q?chore:=20=EA=B8=B0=EC=A1=B4=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=EB=B3=80=EC=88=98=EB=A5=BC=20=EC=9C=A0=EC=A7=80?= =?UTF-8?q?=ED=95=98=EB=A9=B0=20sh=20=EC=8B=A4=ED=96=89=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EB=B3=80=EA=B2=BD=20(#888)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/zero-downtime-deploy-test-cd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/zero-downtime-deploy-test-cd.yml b/.github/workflows/zero-downtime-deploy-test-cd.yml index 93b8ce9c0..ff91878ac 100644 --- a/.github/workflows/zero-downtime-deploy-test-cd.yml +++ b/.github/workflows/zero-downtime-deploy-test-cd.yml @@ -77,6 +77,6 @@ jobs: PROFILE_VAR: "dev" run: | chmod +x ./deploy.sh - sudo ./deploy.sh + sudo -E ./deploy.sh working-directory: ${{ env.APPLICATION_DIRECTORY }}/app From 34fdfd845069ceb91bfb785de4c6011d16ba7877 Mon Sep 17 00:00:00 2001 From: ImxYJL <111052302+ImxYJL@users.noreply.github.com> Date: Mon, 21 Oct 2024 16:35:34 +0900 Subject: [PATCH 33/49] =?UTF-8?q?[FE]=20feat:=20=EC=83=88=EB=A1=9C?= =?UTF-8?q?=EA=B3=A0=EC=B9=A8=20=EB=B0=8F=20=EC=B0=BD=EC=9D=84=20=EB=8B=AB?= =?UTF-8?q?=EB=8A=94=20=EB=8F=99=EC=9E=91=EC=97=90=20=EB=8C=80=ED=95=B4=20?= =?UTF-8?q?=EA=B2=BD=EA=B3=A0=20alert=20=EB=9D=84=EC=9A=B0=EA=B8=B0=20(#85?= =?UTF-8?q?1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: isOpenModalDisablingBlocker의 조건에서 navigateConfirm을 검사하던 문제 수정 * feat: useNavigateBlocker에 새로고침 및 페이지 삭제 전 alert를 띄우는 기능 추가 * refactor: useBlocker를 이용해 라우팅을 막아야 하는지를 판단하는 변수 이름을 isUnblocked로 수정 및 그에 맞게 조건문 변경 * refactor: useBlocker를 이용해 라우팅이 막혀 있는 상태인지를 나타내는 변수의 이름을 isNavigationUnblocked로 수정 * refactor: 제출 확인 모달이 띄워진 상황에서도 뒤로가기를 누르면 이동 확인 모달을 띄울 수 있도록 수정 --- .../form/components/CardForm/index.tsx | 7 ++--- .../form/hooks/useNavigateBlocker.ts | 28 +++++++++++++------ .../modals/hooks/useCardFormModal.ts | 2 -- 3 files changed, 21 insertions(+), 16 deletions(-) diff --git a/frontend/src/pages/ReviewWritingPage/form/components/CardForm/index.tsx b/frontend/src/pages/ReviewWritingPage/form/components/CardForm/index.tsx index 2c219b5d7..158441881 100644 --- a/frontend/src/pages/ReviewWritingPage/form/components/CardForm/index.tsx +++ b/frontend/src/pages/ReviewWritingPage/form/components/CardForm/index.tsx @@ -36,19 +36,16 @@ const CardForm = () => { useUpdateDefaultAnswers(); // 모달 - const { handleOpenModal, closeModal, isOpen, isOpenModalDisablingBlocker } = useCardFormModal(); + const { handleOpenModal, closeModal, isOpen } = useCardFormModal(); const handleNavigateConfirmButtonClick = () => { closeModal(CARD_FORM_MODAL_KEY.navigateConfirm); - if (blocker.proceed) { - blocker.proceed(); - } + if (blocker.proceed) blocker.proceed(); }; // 작성 중인 답변이 있는 경우 페이지 이동을 막는 기능 const { blocker } = useNavigateBlocker({ - isOpenModalDisablingBlocker, openNavigateConfirmModal: () => handleOpenModal('navigateConfirm'), }); diff --git a/frontend/src/pages/ReviewWritingPage/form/hooks/useNavigateBlocker.ts b/frontend/src/pages/ReviewWritingPage/form/hooks/useNavigateBlocker.ts index f41c53799..d3bc57ab5 100644 --- a/frontend/src/pages/ReviewWritingPage/form/hooks/useNavigateBlocker.ts +++ b/frontend/src/pages/ReviewWritingPage/form/hooks/useNavigateBlocker.ts @@ -5,26 +5,28 @@ import { useRecoilValue } from 'recoil'; import { answerMapAtom } from '@/recoil'; interface UseNavigateBlockerProps { - isOpenModalDisablingBlocker: boolean; openNavigateConfirmModal: () => void; } -/** - * @param isOpenModalDisablingBlocker : 이동 확인 모달과 리뷰 제출 확인 모달 모달들이 열려있는 지 여부 (모달의 이동/제출 버튼 클릭 시 페이지 이동해야하기 때문에 필요) - * 작성한 답변이 있는 상태에서 작성한 페이지에서 다른 페이지로 이동하려할때 이동을 막거나, 이동을 진행하는 blocker를 반환하는 훅 - */ -const useNavigateBlocker = ({ isOpenModalDisablingBlocker, openNavigateConfirmModal }: UseNavigateBlockerProps) => { + +const useNavigateBlocker = ({ openNavigateConfirmModal }: UseNavigateBlockerProps) => { const answerMap = useRecoilValue(answerMapAtom); const isAnswerInProgress = () => { if (!answerMap) return false; - return [...answerMap.values()].some((answer) => !!answer.selectedOptionIds?.length || !!answer.text?.length); }; + // 페이지 새로고침 및 닫기에 대한 처리: 브라우저 기본 alert 등장 + const handleNavigationBlock = (event: BeforeUnloadEvent) => { + if (isAnswerInProgress()) event.preventDefault(); + }; + + // 페이지 히스토리에 영향을 주는 페이지 이동 처리: useBlocker 이용 const blocker = useBlocker(({ currentLocation, nextLocation }) => { - if (isOpenModalDisablingBlocker) return false; const isLeavingPage = currentLocation.pathname !== nextLocation.pathname; - return isAnswerInProgress() && isLeavingPage; + const isMoveToCompletePage = nextLocation.pathname.includes('complete'); + // 리뷰 작성 완료 페이지로 이동하는 url 변경인 경우에는 navigateConfirm 모달을 띄우지 않음 + return isAnswerInProgress() && isLeavingPage && !isMoveToCompletePage; }); useEffect(() => { @@ -33,6 +35,14 @@ const useNavigateBlocker = ({ isOpenModalDisablingBlocker, openNavigateConfirmMo } }, [blocker]); + useEffect(() => { + window.addEventListener('beforeunload', handleNavigationBlock); + + return () => { + window.removeEventListener('beforeunload', handleNavigationBlock); + }; + }, [answerMap]); + return { blocker, }; diff --git a/frontend/src/pages/ReviewWritingPage/modals/hooks/useCardFormModal.ts b/frontend/src/pages/ReviewWritingPage/modals/hooks/useCardFormModal.ts index f4898fa2a..a7e3a53ce 100644 --- a/frontend/src/pages/ReviewWritingPage/modals/hooks/useCardFormModal.ts +++ b/frontend/src/pages/ReviewWritingPage/modals/hooks/useCardFormModal.ts @@ -13,8 +13,6 @@ const useCardFormModal = () => { handleOpenModal, closeModal, isOpen, - isOpenModalDisablingBlocker: - isOpen(CARD_FORM_MODAL_KEY.navigateConfirm) || isOpen(CARD_FORM_MODAL_KEY.submitConfirm), }; }; From ba205b014d59c19ef262b13a3301a2cff831b7ff Mon Sep 17 00:00:00 2001 From: Fe <64690761+chysis@users.noreply.github.com> Date: Mon, 21 Oct 2024 17:47:00 +0900 Subject: [PATCH 34/49] =?UTF-8?q?[FE]=20fix:=20=EB=AA=A8=EC=95=84=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(#858)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: 받은 리뷰가 0개인 경우 모아보기 페이지에 널~ 문구가 보이도록 수정 * chore: 중복되는 목 데이터 제거 * refactor: 널~ 문구를 출력하는 컴포넌트 위치 변경 및 문구를 props로 전달 가능하게 수정 * chore: 널~ 과 함께 보여줄 문구를 나누고 전체 리뷰가 없는 경우 적용 * chore: 질문별로 답변이 없는 경우 널~ 표시하도록 설정 및 해당 로직 분리 * chore: 변수명 수정 및 주석 추가 * chore: 질문 이름을 문자열 치환 과정을 거치도록 수정 * fix: 형광펜 에디터가 개행 문자로 끊은 문장 단위로 disc를 달지 않도록 상위 요소에 설정 * design: 모바일 환경에서 널~ 문자의 폰트 크기 조절 * chore: 목 데이터에서 불필요한 부분 제거 * refactor: 형광펜 에디터 답변에 시맨틱 태그 적용 * chore: ReviewEmptySection에 절대경로 적용 * feat: ReviewInfoData를 관리하는 context 및 provider 정의 - 리뷰이 이름 - 프로젝트 이름 - 총 받은 리뷰 개수 를 관리합니다 Co-Authored-By: ImxYJL <111052302+ImxYJL@users.noreply.github.com> * feat: 레이아웃 상단에서 provider 적용 Co-Authored-By: ImxYJL <111052302+ImxYJL@users.noreply.github.com> * chore: 리뷰 목록 페이지에서 context 구독 Co-Authored-By: ImxYJL <111052302+ImxYJL@users.noreply.github.com> * chore: 리뷰 모아보기 페이지에서 context 구독 및 pageContent 분리 Co-Authored-By: ImxYJL <111052302+ImxYJL@users.noreply.github.com> * chore: ReviewInfoSection에서 context 구독 Co-Authored-By: ImxYJL <111052302+ImxYJL@users.noreply.github.com> * design: 아코디언 헤더 및 컨텐츠 분리, 'Q' 부분 분리 * fix: 첫 페이지 렌더링에서 아코디언이 열렸다 닫히는 것처럼 보이는 문제 수정 * chore: 누락된 파일 추가 * refactor: 삼항연산자 제거 * chore: useContext의 기본값 제거 * chore: ReviewDisplayLayout 변수명 수정 * chore: 누락됐던 답변 목록 형식으로 보여주는 코드 추가 * chore: 폴더명을 파일명에 맞추어 수정 * fix: 변경된 파일명에 따른 import 수정 * fix: 드롭다운에서 다른 섹션을 선택했을 때 아코디언의 isOpened 상태가 유지되는 오류 수정 * chore: 형광펜 list 구조 수정 및 모아보기 페이지에 적용 * design: 아코디언 gap 조정 * fix: contextAPI 구조 수정 --------- Co-authored-by: ImxYJL <111052302+ImxYJL@users.noreply.github.com> --- .../src/components/common/Accordion/index.tsx | 34 ++++---- .../src/components/common/Accordion/styles.ts | 26 ++++-- .../common/ReviewEmptySection/index.tsx | 16 ++++ .../common}/ReviewEmptySection/styles.ts | 6 +- frontend/src/components/common/index.tsx | 1 + .../components/EditorLineBlock/index.tsx | 6 +- .../components/EditorLineBlock/style.ts | 8 -- .../components/HighlightEditor/index.tsx | 32 ++++---- .../components/HighlightEditor/style.ts | 13 +++ .../ReviewInfoDataProvider.tsx | 21 +++++ .../components/ReviewInfoSection/index.tsx | 11 ++- .../layouts/ReviewDisplayLayout/index.tsx | 31 ++++--- .../layouts/ReviewDisplayLayout/styles.ts | 2 +- frontend/src/constants/review.ts | 5 ++ frontend/src/hooks/useAccordion.ts | 21 ++++- .../src/mocks/mockData/reviewCollection.ts | 8 +- .../ReviewCollectionPageContents/index.tsx | 81 +++++++++++++++++++ .../ReviewCollectionPageContents}/styles.ts | 0 .../src/pages/ReviewCollectionPage/index.tsx | 58 +------------ .../components/PageContents/index.tsx | 64 --------------- .../components/ReviewEmptySection/index.tsx | 12 --- .../ReviewListPageContents/index.tsx | 67 +++++++++++++++ .../styles.ts | 0 frontend/src/pages/ReviewListPage/index.tsx | 9 ++- 24 files changed, 315 insertions(+), 217 deletions(-) create mode 100644 frontend/src/components/common/ReviewEmptySection/index.tsx rename frontend/src/{pages/ReviewListPage/components => components/common}/ReviewEmptySection/styles.ts (94%) delete mode 100644 frontend/src/components/highlight/components/EditorLineBlock/style.ts create mode 100644 frontend/src/components/layouts/ReviewDisplayLayout/ReviewInfoDataProvider.tsx create mode 100644 frontend/src/pages/ReviewCollectionPage/components/ReviewCollectionPageContents/index.tsx rename frontend/src/pages/ReviewCollectionPage/{ => components/ReviewCollectionPageContents}/styles.ts (100%) delete mode 100644 frontend/src/pages/ReviewListPage/components/PageContents/index.tsx delete mode 100644 frontend/src/pages/ReviewListPage/components/ReviewEmptySection/index.tsx create mode 100644 frontend/src/pages/ReviewListPage/components/ReviewListPageContents/index.tsx rename frontend/src/pages/ReviewListPage/components/{PageContents => ReviewListPageContents}/styles.ts (100%) diff --git a/frontend/src/components/common/Accordion/index.tsx b/frontend/src/components/common/Accordion/index.tsx index 5b546f941..04242249e 100644 --- a/frontend/src/components/common/Accordion/index.tsx +++ b/frontend/src/components/common/Accordion/index.tsx @@ -1,5 +1,3 @@ -import { useEffect, useRef, useState } from 'react'; - import DownArrowIcon from '@/assets/downArrow.svg'; import useAccordion from '@/hooks/useAccordion'; import { EssentialPropsWithChildren } from '@/types'; @@ -12,24 +10,28 @@ interface AccordionProps { } const Accordion = ({ title, isInitiallyOpened = false, children }: EssentialPropsWithChildren) => { - const { isOpened, handleAccordionButtonClick } = useAccordion({ isInitiallyOpened }); - const [contentHeight, setContentHeight] = useState(0); - const contentRef = useRef(null); - - useEffect(() => { - if (contentRef.current) { - setContentHeight(contentRef.current.clientHeight); - } - }, [isOpened]); + const { isOpened, contentHeight, contentRef, isFirstRender, handleAccordionButtonClick } = useAccordion({ + isInitiallyOpened, + }); return ( - - {title} - - + + + + Q. + {title} + + + + - + {children} diff --git a/frontend/src/components/common/Accordion/styles.ts b/frontend/src/components/common/Accordion/styles.ts index 310a3caba..b701f6c60 100644 --- a/frontend/src/components/common/Accordion/styles.ts +++ b/frontend/src/components/common/Accordion/styles.ts @@ -3,15 +3,15 @@ import styled from '@emotion/styled'; interface AccordionStyleProps { $isOpened: boolean; $contentHeight?: number; + $isFirstRender?: boolean; } export const AccordionContainer = styled.div` display: flex; flex-direction: column; - gap: ${({ $isOpened }) => ($isOpened ? '2rem' : 0)}; + gap: ${({ $isOpened }) => ($isOpened ? '1rem' : 0)}; width: 100%; - padding: 1rem; background-color: ${({ theme, $isOpened }) => ($isOpened ? theme.colors.white : theme.colors.lightGray)}; border: 0.1rem solid ${({ theme }) => theme.colors.placeholder}; @@ -22,6 +22,12 @@ export const AccordionContainer = styled.div` } `; +export const AccordionHeader = styled.div` + display: flex; + padding: 1rem; + border-bottom: ${({ $isOpened, theme }) => $isOpened && `0.1rem solid ${theme.colors.placeholder}`}; +`; + export const AccordionButton = styled.button` display: flex; gap: 1rem; @@ -34,10 +40,13 @@ export const AccordionButton = styled.button` `; export const AccordionTitle = styled.p` + display: flex; + font-weight: ${({ theme }) => theme.fontWeight.semibold}; text-align: left; - ::before { - content: 'Q. '; - } +`; + +export const QuestionMark = styled.p` + margin-right: 0.5rem; `; export const ArrowIcon = styled.img` @@ -50,7 +59,8 @@ export const AccordionContentsWrapper = styled.div` `; export const AccordionContents = styled.div` - margin-top: ${({ $isOpened, $contentHeight }) => ($isOpened ? '0' : `-${$contentHeight}px`)}; - opacity: ${({ $isOpened }) => ($isOpened ? '1' : '0')}; - transition: 0.3s ease; + margin-top: ${({ $isOpened, $contentHeight }) => ($isOpened ? 0 : `-${$contentHeight! * 0.1}rem`)}; + padding: 1rem; + opacity: ${({ $isOpened }) => ($isOpened ? 1 : 0)}; + transition: ${({ $isFirstRender }) => ($isFirstRender === true ? 'none' : '0.3s ease-in')}; `; diff --git a/frontend/src/components/common/ReviewEmptySection/index.tsx b/frontend/src/components/common/ReviewEmptySection/index.tsx new file mode 100644 index 000000000..514427bce --- /dev/null +++ b/frontend/src/components/common/ReviewEmptySection/index.tsx @@ -0,0 +1,16 @@ +import * as S from './styles'; + +interface ReviewEmptySectionProps { + content: string; +} + +const ReviewEmptySection = ({ content }: ReviewEmptySectionProps) => { + return ( + + 널~ + {content} + + ); +}; + +export default ReviewEmptySection; diff --git a/frontend/src/pages/ReviewListPage/components/ReviewEmptySection/styles.ts b/frontend/src/components/common/ReviewEmptySection/styles.ts similarity index 94% rename from frontend/src/pages/ReviewListPage/components/ReviewEmptySection/styles.ts rename to frontend/src/components/common/ReviewEmptySection/styles.ts index e93bc9905..763b21f2f 100644 --- a/frontend/src/pages/ReviewListPage/components/ReviewEmptySection/styles.ts +++ b/frontend/src/components/common/ReviewEmptySection/styles.ts @@ -14,15 +14,15 @@ export const NullText = styled.span` color: #e0e1e3; ${media.small} { - font-size: 20rem; + font-size: 16rem; } ${media.xSmall} { - font-size: 18rem; + font-size: 15rem; } ${media.xxSmall} { - font-size: 16rem; + font-size: 14rem; } `; diff --git a/frontend/src/components/common/index.tsx b/frontend/src/components/common/index.tsx index 5c2cdfdbe..3fe540459 100644 --- a/frontend/src/components/common/index.tsx +++ b/frontend/src/components/common/index.tsx @@ -11,4 +11,5 @@ export { default as Accordion } from './Accordion'; export { default as Dropdown } from './Dropdown'; export { default as OptionSwitch } from './OptionSwitch'; +export { default as ReviewEmptySection } from './ReviewEmptySection'; export * from './modals'; diff --git a/frontend/src/components/highlight/components/EditorLineBlock/index.tsx b/frontend/src/components/highlight/components/EditorLineBlock/index.tsx index 5807e6fe4..cf6c533a6 100644 --- a/frontend/src/components/highlight/components/EditorLineBlock/index.tsx +++ b/frontend/src/components/highlight/components/EditorLineBlock/index.tsx @@ -3,8 +3,6 @@ import { EditorLine, HighlightRange } from '@/types'; import Syntax from '../Syntax'; -import * as S from './style'; - interface EditorLineBlockProps { line: EditorLine; lineIndex: number; @@ -62,9 +60,9 @@ const EditorLineBlock = ({ line, lineIndex }: EditorLineBlockProps) => { }; return ( - +

{renderSentenceList()} - +

); }; diff --git a/frontend/src/components/highlight/components/EditorLineBlock/style.ts b/frontend/src/components/highlight/components/EditorLineBlock/style.ts deleted file mode 100644 index 9774d0ecb..000000000 --- a/frontend/src/components/highlight/components/EditorLineBlock/style.ts +++ /dev/null @@ -1,8 +0,0 @@ -import styled from '@emotion/styled'; - -export const EditorLineBlock = styled.p` - display: list-item; - margin-left: 3rem; - text-indent: -2.2rem; - list-style-type: disc; -`; diff --git a/frontend/src/components/highlight/components/HighlightEditor/index.tsx b/frontend/src/components/highlight/components/HighlightEditor/index.tsx index 313e81e6f..ce3ae5955 100644 --- a/frontend/src/components/highlight/components/HighlightEditor/index.tsx +++ b/frontend/src/components/highlight/components/HighlightEditor/index.tsx @@ -94,21 +94,23 @@ const HighlightEditor = ({ questionId, answerList, handleErrorModal }: Highlight /> - {[...editorAnswerMap.values()].map(({ answerId, answerIndex, lineList }) => ( -
- {lineList.map((line, index) => ( - - ))} -
- ))} + + {[...editorAnswerMap.values()].map(({ answerId, answerIndex, lineList }) => ( + + {lineList.map((line, index) => ( + + ))} + + ))} + {isEditable && dragHighlightButtonPosition && ( ` display: inline-block; color: ${({ $isEditable, theme }) => ($isEditable ? theme.colors.primary : theme.colors.gray)}; `; + +export const AnswerList = styled.ul` + list-style: disc; + list-style-position: outside; +`; + +export const AnswerListItem = styled.li` + margin-bottom: 1rem; + margin-left: 2rem; + &::marker { + margin: 0; + } +`; diff --git a/frontend/src/components/layouts/ReviewDisplayLayout/ReviewInfoDataProvider.tsx b/frontend/src/components/layouts/ReviewDisplayLayout/ReviewInfoDataProvider.tsx new file mode 100644 index 000000000..76b3690d7 --- /dev/null +++ b/frontend/src/components/layouts/ReviewDisplayLayout/ReviewInfoDataProvider.tsx @@ -0,0 +1,21 @@ +import { createContext } from 'react'; + +import { useReviewInfoData } from './hooks'; + +interface ReviewInfoData { + revieweeName: string; + projectName: string; + totalReviewCount: number; +} + +export const ReviewInfoDataContext = createContext({ + revieweeName: '', + projectName: '', + totalReviewCount: 0, +}); + +export const ReviewInfoDataProvider = ({ children }: { children: React.ReactNode }) => { + const reviewInfoData = useReviewInfoData(); + + return {children}; +}; diff --git a/frontend/src/components/layouts/ReviewDisplayLayout/components/ReviewInfoSection/index.tsx b/frontend/src/components/layouts/ReviewDisplayLayout/components/ReviewInfoSection/index.tsx index 1a8ab689a..4180fb0bd 100644 --- a/frontend/src/components/layouts/ReviewDisplayLayout/components/ReviewInfoSection/index.tsx +++ b/frontend/src/components/layouts/ReviewDisplayLayout/components/ReviewInfoSection/index.tsx @@ -1,15 +1,18 @@ +import { useContext } from 'react'; + import { calculateParticle } from '@/utils'; +import { ReviewInfoDataContext } from '../../ReviewInfoDataProvider'; + import * as S from './styles'; export interface ReviewInfoSectionProps { - revieweeName: string; isReviewList: boolean; - projectName: string; - totalReviewCount?: number; } -const ReviewInfoSection = ({ projectName, revieweeName, totalReviewCount, isReviewList }: ReviewInfoSectionProps) => { +const ReviewInfoSection = ({ isReviewList }: ReviewInfoSectionProps) => { + const { revieweeName, projectName, totalReviewCount } = useContext(ReviewInfoDataContext); + const revieweeNameWithParticle = calculateParticle({ target: revieweeName, particles: { withFinalConsonant: '이', withoutFinalConsonant: '가' }, diff --git a/frontend/src/components/layouts/ReviewDisplayLayout/index.tsx b/frontend/src/components/layouts/ReviewDisplayLayout/index.tsx index 73545864a..0926b6e46 100644 --- a/frontend/src/components/layouts/ReviewDisplayLayout/index.tsx +++ b/frontend/src/components/layouts/ReviewDisplayLayout/index.tsx @@ -2,31 +2,28 @@ import { TopButton, OptionSwitch } from '@/components/common'; import { EssentialPropsWithChildren } from '@/types'; import ReviewInfoSection from './components/ReviewInfoSection'; -import { useReviewInfoData, useReviewDisplayLayoutOptions } from './hooks'; +import { useReviewDisplayLayoutOptions } from './hooks'; +import { ReviewInfoDataProvider } from './ReviewInfoDataProvider'; import * as S from './styles'; -interface ReviewDisplayLayoutProps { +interface ReviewDisplayLayoutProps extends EssentialPropsWithChildren { isReviewList: boolean; } -const ReviewDisplayLayout = ({ isReviewList, children }: EssentialPropsWithChildren) => { +const ReviewDisplayLayout = ({ isReviewList, children }: ReviewDisplayLayoutProps) => { const reviewDisplayLayoutOptions = useReviewDisplayLayoutOptions(); - const { revieweeName, projectName, totalReviewCount } = useReviewInfoData(); return ( - - - - - - - {children} - + + + + + + + + {children} + + ); }; diff --git a/frontend/src/components/layouts/ReviewDisplayLayout/styles.ts b/frontend/src/components/layouts/ReviewDisplayLayout/styles.ts index 2cbf1e1bb..f75f20032 100644 --- a/frontend/src/components/layouts/ReviewDisplayLayout/styles.ts +++ b/frontend/src/components/layouts/ReviewDisplayLayout/styles.ts @@ -1,6 +1,6 @@ import styled from '@emotion/styled'; -export const ReviewDisplayLayout = styled.div` +export const ReviewDisplayLayoutContainer = styled.div` display: flex; flex-direction: column; width: 90%; diff --git a/frontend/src/constants/review.ts b/frontend/src/constants/review.ts index fd6edeb5a..8535db266 100644 --- a/frontend/src/constants/review.ts +++ b/frontend/src/constants/review.ts @@ -8,3 +8,8 @@ export const REVIEW = { export const REVIEW_MESSAGE = { answerMaxLength: `최대 ${REVIEW.answerMaxLength}자까지 입력 가능해요`, }; + +export const REVIEW_EMPTY = { + noReviewInTotal: '아직 받은 리뷰가 없어요!', + noReviewInQuestion: '이 질문은 아직 받은 답변이 없어요!', +}; diff --git a/frontend/src/hooks/useAccordion.ts b/frontend/src/hooks/useAccordion.ts index a9055bbbc..d02c78fcf 100644 --- a/frontend/src/hooks/useAccordion.ts +++ b/frontend/src/hooks/useAccordion.ts @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; interface UseAccordionProps { isInitiallyOpened: boolean; @@ -6,6 +6,22 @@ interface UseAccordionProps { const useAccordion = ({ isInitiallyOpened }: UseAccordionProps) => { const [isOpened, setIsOpened] = useState(isInitiallyOpened); + const [contentHeight, setContentHeight] = useState(0); + const [isFirstRender, setIsFirstRender] = useState(true); + const contentRef = useRef(null); + + useLayoutEffect(() => { + if (!contentRef.current) return; + + setContentHeight(contentRef.current.clientHeight); + }, []); + + // contentHeight가 계산된 이후에 isFirstRender 조작 + useEffect(() => { + if (contentHeight > 0) { + setIsFirstRender(false); + } + }, [contentHeight]); const handleAccordionButtonClick = () => { setIsOpened((prev) => !prev); @@ -13,6 +29,9 @@ const useAccordion = ({ isInitiallyOpened }: UseAccordionProps) => { return { isOpened, + contentHeight, + contentRef, + isFirstRender, handleAccordionButtonClick, }; }; diff --git a/frontend/src/mocks/mockData/reviewCollection.ts b/frontend/src/mocks/mockData/reviewCollection.ts index 27e0db910..f28f31b52 100644 --- a/frontend/src/mocks/mockData/reviewCollection.ts +++ b/frontend/src/mocks/mockData/reviewCollection.ts @@ -1,10 +1,4 @@ -import { GroupedReviews, GroupedSection, ReviewSummary } from '@/types'; - -export const REVIEW_SUMMARY_MOCK_DATA: ReviewSummary = { - projectName: '리뷰미', - revieweeName: '에프이', - reviewCount: 5, -}; +import { GroupedReviews, GroupedSection } from '@/types'; export const GROUPED_SECTION_MOCK_DATA: GroupedSection = { sections: [ diff --git a/frontend/src/pages/ReviewCollectionPage/components/ReviewCollectionPageContents/index.tsx b/frontend/src/pages/ReviewCollectionPage/components/ReviewCollectionPageContents/index.tsx new file mode 100644 index 000000000..895efe707 --- /dev/null +++ b/frontend/src/pages/ReviewCollectionPage/components/ReviewCollectionPageContents/index.tsx @@ -0,0 +1,81 @@ +import React, { useContext, useState } from 'react'; + +import { Accordion, Dropdown, HighlightEditorContainer } from '@/components'; +import { DropdownItem } from '@/components/common/Dropdown'; +import ReviewEmptySection from '@/components/common/ReviewEmptySection'; +import { ReviewInfoDataContext } from '@/components/layouts/ReviewDisplayLayout/ReviewInfoDataProvider'; +import { REVIEW_EMPTY } from '@/constants'; +import { GroupedReview } from '@/types'; +import { substituteString } from '@/utils'; + +import useGetGroupedReviews from '../../hooks/useGetGroupedReviews'; +import useGetSectionList from '../../hooks/useGetSectionList'; +import DoughnutChart from '../DoughnutChart'; + +import * as S from './styles'; + +const ReviewCollectionPageContents = () => { + const { revieweeName, projectName, totalReviewCount } = useContext(ReviewInfoDataContext); + + const { data: reviewSectionList } = useGetSectionList(); + const dropdownSectionList = reviewSectionList.sections.map((section) => { + return { text: section.name, value: section.id }; + }); + + const [selectedSection, setSelectedSection] = useState(dropdownSectionList[0]); + const { data: groupedReviews } = useGetGroupedReviews({ sectionId: selectedSection.value as number }); + + const renderContent = (review: GroupedReview) => { + if (review.question.type === 'CHECKBOX') { + const hasNoCheckboxAnswer = review.votes?.every((vote) => vote.count === 0); + + return hasNoCheckboxAnswer ? ( + + ) : ( + + ); + } + + if (review.answers?.length === 0) { + return ; + } + + return ; + }; + + if (totalReviewCount === 0) { + return ; + } + + return ( + + + section.value === selectedSection.value)!} + handleSelect={(item) => setSelectedSection(item)} + /> + + + {groupedReviews.reviews.map((review, index) => { + const parsedQuestionName = substituteString({ + content: review.question.name, + variables: { revieweeName, projectName }, + }); + + return ( + + {renderContent(review)} + + ); + })} + + + ); +}; + +export default ReviewCollectionPageContents; diff --git a/frontend/src/pages/ReviewCollectionPage/styles.ts b/frontend/src/pages/ReviewCollectionPage/components/ReviewCollectionPageContents/styles.ts similarity index 100% rename from frontend/src/pages/ReviewCollectionPage/styles.ts rename to frontend/src/pages/ReviewCollectionPage/components/ReviewCollectionPageContents/styles.ts diff --git a/frontend/src/pages/ReviewCollectionPage/index.tsx b/frontend/src/pages/ReviewCollectionPage/index.tsx index 5e5464480..c582d30e6 100644 --- a/frontend/src/pages/ReviewCollectionPage/index.tsx +++ b/frontend/src/pages/ReviewCollectionPage/index.tsx @@ -1,65 +1,15 @@ -import { useState } from 'react'; - -import { - Accordion, - AuthAndServerErrorFallback, - Dropdown, - ErrorSuspenseContainer, - TopButton, - HighlightEditorContainer, -} from '@/components'; -import { DropdownItem } from '@/components/common/Dropdown'; +import { AuthAndServerErrorFallback, ErrorSuspenseContainer, TopButton } from '@/components'; import ReviewDisplayLayout from '@/components/layouts/ReviewDisplayLayout'; -import DoughnutChart from './components/DoughnutChart'; -import useGetGroupedReviews from './hooks/useGetGroupedReviews'; -import useGetSectionList from './hooks/useGetSectionList'; -import * as S from './styles'; +import ReviewCollectionPageContents from './components/ReviewCollectionPageContents'; const ReviewCollectionPage = () => { - const { data: reviewSectionList } = useGetSectionList(); - const dropdownSectionList = reviewSectionList.sections.map((section) => { - return { text: section.name, value: section.id }; - }); - - const [selectedSection, setSelectedSection] = useState(dropdownSectionList[0]); - const { data: groupedReviews } = useGetGroupedReviews({ sectionId: selectedSection.value as number }); - - groupedReviews.reviews.forEach((review) => { - review.votes?.sort((voteA, voteB) => voteB.count - voteA.count); - }); - return ( - - - section.value === selectedSection.value)!} - handleSelect={(item) => setSelectedSection(item)} - /> - - - {groupedReviews.reviews.map((review, index) => { - return ( - - {review.question.type === 'CHECKBOX' ? ( - - ) : ( - - {review.answers && ( - - )} - - )} - - ); - })} - - + + - ); }; diff --git a/frontend/src/pages/ReviewListPage/components/PageContents/index.tsx b/frontend/src/pages/ReviewListPage/components/PageContents/index.tsx deleted file mode 100644 index d9f111b04..000000000 --- a/frontend/src/pages/ReviewListPage/components/PageContents/index.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { useNavigate } from 'react-router'; - -import UndraggableWrapper from '@/components/common/UndraggableWrapper'; -import ReviewDisplayLayout from '@/components/layouts/ReviewDisplayLayout'; -import ReviewCard from '@/components/ReviewCard'; -import { ROUTE } from '@/constants/route'; -import { useGetReviewList, useSearchParamAndQuery } from '@/hooks'; - -import { useInfiniteScroll } from '../../hooks'; -import ReviewEmptySection from '../ReviewEmptySection'; - -import * as S from './styles'; - -const PageContents = () => { - const navigate = useNavigate(); - - const { data, fetchNextPage, isLoading, isSuccess } = useGetReviewList(); - - const { param: reviewRequestCode } = useSearchParamAndQuery({ - paramKey: 'reviewRequestCode', - }); - - const handleReviewClick = (id: number) => { - navigate(`/${ROUTE.detailedReview}/${reviewRequestCode}/${id}`); - }; - - const isLastPage = data.pages[data.pages.length - 1].isLastPage; - const reviews = data.pages.flatMap((page) => page.reviews); - - const lastReviewElementRef = useInfiniteScroll({ - fetchNextPage, - isLoading, - isLastPage, - }); - - return ( - isSuccess && ( - - {reviews.length === 0 ? ( - - ) : ( - - {reviews.map((review, index) => { - const isLastReview = reviews.length === index + 1; - return ( - - handleReviewClick(review.reviewId)} - /> -
- - ); - })} - - )} - - ) - ); -}; - -export default PageContents; diff --git a/frontend/src/pages/ReviewListPage/components/ReviewEmptySection/index.tsx b/frontend/src/pages/ReviewListPage/components/ReviewEmptySection/index.tsx deleted file mode 100644 index 328107a6f..000000000 --- a/frontend/src/pages/ReviewListPage/components/ReviewEmptySection/index.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import * as S from './styles'; - -const ReviewEmptySection = () => { - return ( - - 널~ - 아직 받은 리뷰가 없어요! - - ); -}; - -export default ReviewEmptySection; diff --git a/frontend/src/pages/ReviewListPage/components/ReviewListPageContents/index.tsx b/frontend/src/pages/ReviewListPage/components/ReviewListPageContents/index.tsx new file mode 100644 index 000000000..172242ad0 --- /dev/null +++ b/frontend/src/pages/ReviewListPage/components/ReviewListPageContents/index.tsx @@ -0,0 +1,67 @@ +import { useContext } from 'react'; +import { useNavigate } from 'react-router'; + +import { ReviewEmptySection } from '@/components'; +import UndraggableWrapper from '@/components/common/UndraggableWrapper'; +import { ReviewInfoDataContext } from '@/components/layouts/ReviewDisplayLayout/ReviewInfoDataProvider'; +import ReviewCard from '@/components/ReviewCard'; +import { REVIEW_EMPTY } from '@/constants'; +import { ROUTE } from '@/constants/route'; +import { useGetReviewList, useSearchParamAndQuery } from '@/hooks'; + +import { useInfiniteScroll } from '../../hooks'; + +import * as S from './styles'; + +const ReviewListPageContents = () => { + const navigate = useNavigate(); + + const { data, fetchNextPage, isLoading, isSuccess } = useGetReviewList(); + const { totalReviewCount } = useContext(ReviewInfoDataContext); + + const { param: reviewRequestCode } = useSearchParamAndQuery({ + paramKey: 'reviewRequestCode', + }); + + const handleReviewClick = (id: number) => { + navigate(`/${ROUTE.detailedReview}/${reviewRequestCode}/${id}`); + }; + + const isLastPage = data.pages[data.pages.length - 1].isLastPage; + const reviews = data.pages.flatMap((page) => page.reviews); + + const lastReviewElementRef = useInfiniteScroll({ + fetchNextPage, + isLoading, + isLastPage, + }); + + if (!isSuccess) return null; + + return ( + <> + {totalReviewCount === 0 ? ( + + ) : ( + + {reviews.map((review, index) => { + const isLastReview = reviews.length === index + 1; + return ( + + handleReviewClick(review.reviewId)} + /> +
+ + ); + })} + + )} + + ); +}; + +export default ReviewListPageContents; diff --git a/frontend/src/pages/ReviewListPage/components/PageContents/styles.ts b/frontend/src/pages/ReviewListPage/components/ReviewListPageContents/styles.ts similarity index 100% rename from frontend/src/pages/ReviewListPage/components/PageContents/styles.ts rename to frontend/src/pages/ReviewListPage/components/ReviewListPageContents/styles.ts diff --git a/frontend/src/pages/ReviewListPage/index.tsx b/frontend/src/pages/ReviewListPage/index.tsx index cf6493396..a2aec6cdf 100644 --- a/frontend/src/pages/ReviewListPage/index.tsx +++ b/frontend/src/pages/ReviewListPage/index.tsx @@ -1,12 +1,15 @@ import { ErrorSuspenseContainer, AuthAndServerErrorFallback, TopButton } from '@/components'; +import ReviewDisplayLayout from '@/components/layouts/ReviewDisplayLayout'; -import PageContents from './components/PageContents'; +import ReviewListPageContents from './components/ReviewListPageContents'; const ReviewListPage = () => { return ( - - + + + + ); }; From cd3b567996a5a253cfae76a2ea2858e1b2b37521 Mon Sep 17 00:00:00 2001 From: badahertz52 Date: Mon, 21 Oct 2024 23:20:09 +0900 Subject: [PATCH 35/49] =?UTF-8?q?[FE]=20feat:=20=ED=98=95=EA=B4=91?= =?UTF-8?q?=ED=8E=9C=20=EB=A9=94=EB=89=B4=20=EB=B0=8F=20=ED=88=B4=ED=8C=81?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84=20(#874)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: line 관련 변수명 변경 (blockElement -> lineElement) * feat: HighlightMenu 컴포넌트 훅 생성 및 기존 관련 코드 변경 * fix: 하이라이트 적용과 미적용 같이 있을 때, isForwardDrag 값 오류 수정 * refactor: 하이라이트 메뉴 사이즈 관련 변수 수정 * design: 하이라이트 메뉴 버튼 스타일 변경 (사이즈 조정 및 hover시 배경색 변경) * fix: 모아보기 페이지- 주관식 답변 개행에도 li marker 표시되는 오류 수정 * refactor: 하이라이트 메뉴 위치 초기화하는 함수명 변경 * fix: 길게 눌렀을 때 삭제되는 버튼 안 뜨는 오류 수정 및 상태명 변경 * feat: 형광펜 기능 알려주는 툴팁 구현 * chore: 불필요한 콘솔 삭제 * fix: useMutateHighlight 테스트 오류 수정 * design: 형광펜 스위치 버튼 디자인 수정 - 꺼짐 : 버튼 왼쪽 - 켜짐: 버튼 오른쪽 * fix: 리뷰 모아보기 데이터 타입 차이로 인한, 형광펜 적용 안되는 오류 수정 원인 :서버가 내려준 데이터 타입과 클라이언트 타입 불일치 * design: EditorLineBlock 스타일 컴포넌트 삭제 * chore: 리뷰 모아보기 목 데이터 변경(형광펜 적용 추가) * chore: 불필요한 코드 삭제 * feat: 리뷰 모아보기- 주관식 답변 Dot 선택되지 않게 처리 및 UndraggableWrapper에 min-width 적용 * feat: 형광펜 메뉴 위치 - 에디터 상단 넘는지 확인하는 기능 추가 * design: 툴팁 그림자, 문자 졍렬 변경 * chore: 주석처리 제거 * chore: sr-only 삭제 * chore: 답변 항목 구문자 alt 빈문자열로 변경 * design: 그림자 폭 수정 * fix:span 자동 줄넘김 오류 수정 * fix : list 마크와 답변 정렬 안되는 오류 수정 및 마크 이미지 삭제 * chore: 불필요한 콘솔 삭제 * refactor: block -> line으로 변경 * fix: 형광펜 더하기 시, endIndex 오류 수정 - 글자 수에서 -1 안 한 부분 있었음 * docs: 리뷰 모아보기 페이지 하이라이트 목 데이터 변경 * style: Syntax에 prettier 적용 * fix: 글자의 마지막에만 형광펜 칠해지지 않는 오류 수정 * fix: 형광펜 있는 영역을 포함한 여러 답변에서 형광펜 더할 때 offset 오류 수정 * refactor: block -> line으로 변경 - 초기에 만든 block 변수명이 곳곳에 남아있어... * chore: 하이라이트 목 핸들러 필요 없는 response 삭제 * fix: 하이라이트 API오류 시, fallback 실행으로 isEditable 세션스토리지 값 삭제되는 오류 수정 * chore: 불필요한 타입(Highlight 삭제) * fix: 같은 답변 다른 줄, 드래그 방향 오류 수정 * fix: 같은 답변 다른 줄 드래그 방향 계산 오류 수정 - 잘못된 변수로 계산함 --- frontend/src/assets/grayHighlighter.svg | 9 -- frontend/src/assets/helper.svg | 9 ++ frontend/src/assets/primaryHighlighter.svg | 9 -- .../common/UndraggableWrapper/styles.ts | 2 + .../DragHighlightButtonContainer/index.tsx | 31 ---- .../components/EditSwitchButton/style.ts | 2 +- .../components/EditorLineBlock/index.tsx | 5 +- .../components/EditorLineBlock/style.ts | 7 + .../components/HighlightButton/index.tsx | 44 +----- .../components/HighlightButton/style.ts | 34 ++--- .../components/HighlightEditor/hooks/index.ts | 4 +- .../hooks/useCheckHighlight.ts | 22 ++- ...osition.ts => useDragHighlightPosition.ts} | 114 +++++++------- .../HighlightEditor/hooks/useEditableState.ts | 3 + .../HighlightEditor/hooks/useHighlight.ts | 83 +++++------ .../hooks/useHighlightEventListener.ts | 50 +++---- .../hooks/useHighlightMenuPosition/index.ts | 42 ++++++ .../useLongPressHighlightButtonPosition.ts | 50 ------- .../hooks/useLongPressHighlightPosition.ts | 80 ++++++++++ .../hooks/useMutateHighlight/index.ts | 10 +- .../hooks/useMutateHighlight/test.ts | 2 +- .../components/HighlightEditor/index.tsx | 86 ++++------- .../components/HighlightEditor/style.ts | 11 +- .../components/HighlightMenu/index.tsx | 45 ++++++ .../components/HighlightMenu/style.ts | 22 +++ .../LongPressHighlightButtonWrapper/index.tsx | 24 --- .../highlight/components/Syntax/style.ts | 4 +- .../highlight/components/Tooltip/index.tsx | 32 ++++ .../highlight/components/Tooltip/style.ts | 55 +++++++ frontend/src/constants/highlight.ts | 22 ++- frontend/src/constants/index.ts | 1 - frontend/src/constants/screenReader.ts | 1 - frontend/src/constants/sessionStorageKey.ts | 1 + frontend/src/mocks/handlers/highlight.ts | 2 +- .../src/mocks/mockData/reviewCollection.ts | 19 ++- frontend/src/styles/globalStyles.ts | 6 - frontend/src/types/highlight.ts | 15 +- frontend/src/utils/highlight/highlighList.ts | 10 +- frontend/src/utils/highlight/selection.ts | 140 ++++++++++-------- 39 files changed, 634 insertions(+), 474 deletions(-) delete mode 100644 frontend/src/assets/grayHighlighter.svg create mode 100644 frontend/src/assets/helper.svg delete mode 100644 frontend/src/assets/primaryHighlighter.svg delete mode 100644 frontend/src/components/highlight/components/DragHighlightButtonContainer/index.tsx create mode 100644 frontend/src/components/highlight/components/EditorLineBlock/style.ts rename frontend/src/components/highlight/components/HighlightEditor/hooks/{useDragHighlightButtonPosition.ts => useDragHighlightPosition.ts} (58%) create mode 100644 frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlightMenuPosition/index.ts delete mode 100644 frontend/src/components/highlight/components/HighlightEditor/hooks/useLongPressHighlightButtonPosition.ts create mode 100644 frontend/src/components/highlight/components/HighlightEditor/hooks/useLongPressHighlightPosition.ts create mode 100644 frontend/src/components/highlight/components/HighlightMenu/index.tsx create mode 100644 frontend/src/components/highlight/components/HighlightMenu/style.ts delete mode 100644 frontend/src/components/highlight/components/LongPressHighlightButtonWrapper/index.tsx create mode 100644 frontend/src/components/highlight/components/Tooltip/index.tsx create mode 100644 frontend/src/components/highlight/components/Tooltip/style.ts delete mode 100644 frontend/src/constants/screenReader.ts diff --git a/frontend/src/assets/grayHighlighter.svg b/frontend/src/assets/grayHighlighter.svg deleted file mode 100644 index 251127658..000000000 --- a/frontend/src/assets/grayHighlighter.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/frontend/src/assets/helper.svg b/frontend/src/assets/helper.svg new file mode 100644 index 000000000..fa94506b3 --- /dev/null +++ b/frontend/src/assets/helper.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/src/assets/primaryHighlighter.svg b/frontend/src/assets/primaryHighlighter.svg deleted file mode 100644 index 23faf8946..000000000 --- a/frontend/src/assets/primaryHighlighter.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/frontend/src/components/common/UndraggableWrapper/styles.ts b/frontend/src/components/common/UndraggableWrapper/styles.ts index 8defcac00..7781b2819 100644 --- a/frontend/src/components/common/UndraggableWrapper/styles.ts +++ b/frontend/src/components/common/UndraggableWrapper/styles.ts @@ -5,4 +5,6 @@ export const Wrapper = styled.div` -moz-user-select: none; -ms-user-select: none; user-select: none; + + min-width: fit-content; `; diff --git a/frontend/src/components/highlight/components/DragHighlightButtonContainer/index.tsx b/frontend/src/components/highlight/components/DragHighlightButtonContainer/index.tsx deleted file mode 100644 index 5256cfb62..000000000 --- a/frontend/src/components/highlight/components/DragHighlightButtonContainer/index.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { Position } from '@/types'; - -import HighlightButton from '../HighlightButton'; - -interface DragHighlightButtonContainerProps { - buttonPosition: Position; - isAddingHighlight: boolean; - addHighlightByDrag: () => void; - removeHighlightByDrag: () => void; -} -/** - *선택된 영역의 하이라이트 적용 여부에 따라 추가 또는 삭제 버튼을 보여주는 컴포넌트 - */ -const DragHighlightButtonContainer = ({ - buttonPosition, - isAddingHighlight, - addHighlightByDrag, - removeHighlightByDrag, -}: DragHighlightButtonContainerProps) => { - return ( - <> - {isAddingHighlight ? ( - - ) : ( - - )} - - ); -}; - -export default DragHighlightButtonContainer; diff --git a/frontend/src/components/highlight/components/EditSwitchButton/style.ts b/frontend/src/components/highlight/components/EditSwitchButton/style.ts index 7e1dcc9da..84b4b7ff1 100644 --- a/frontend/src/components/highlight/components/EditSwitchButton/style.ts +++ b/frontend/src/components/highlight/components/EditSwitchButton/style.ts @@ -17,7 +17,7 @@ export const EditSwitchButton = styled.button` `; export const Circle = styled.div` - transform: translateX(${({ $isEditable }) => ($isEditable ? 0 : '1.5rem')}); + transform: translateX(${({ $isEditable }) => ($isEditable ? '1.5rem' : 0)}); width: 1rem; height: 1rem; diff --git a/frontend/src/components/highlight/components/EditorLineBlock/index.tsx b/frontend/src/components/highlight/components/EditorLineBlock/index.tsx index cf6c533a6..692757094 100644 --- a/frontend/src/components/highlight/components/EditorLineBlock/index.tsx +++ b/frontend/src/components/highlight/components/EditorLineBlock/index.tsx @@ -3,6 +3,7 @@ import { EditorLine, HighlightRange } from '@/types'; import Syntax from '../Syntax'; +import * as S from './style'; interface EditorLineBlockProps { line: EditorLine; lineIndex: number; @@ -60,9 +61,9 @@ const EditorLineBlock = ({ line, lineIndex }: EditorLineBlockProps) => { }; return ( -

+ {renderSentenceList()} -

+ ); }; diff --git a/frontend/src/components/highlight/components/EditorLineBlock/style.ts b/frontend/src/components/highlight/components/EditorLineBlock/style.ts new file mode 100644 index 000000000..789fa6ace --- /dev/null +++ b/frontend/src/components/highlight/components/EditorLineBlock/style.ts @@ -0,0 +1,7 @@ +import styled from '@emotion/styled'; + +export const Line = styled.p` + word-break: break-all; + overflow-wrap: break-word; + white-space: normal; +`; diff --git a/frontend/src/components/highlight/components/HighlightButton/index.tsx b/frontend/src/components/highlight/components/HighlightButton/index.tsx index 1e8cdd8fb..ccaccda97 100644 --- a/frontend/src/components/highlight/components/HighlightButton/index.tsx +++ b/frontend/src/components/highlight/components/HighlightButton/index.tsx @@ -1,50 +1,28 @@ import EraserIcon from '@/assets/eraser.svg'; import HighlighterIcon from '@/assets/highlighter.svg'; import TrashIcon from '@/assets/trash.svg'; -import { - HIGHLIGHT__TOGGLE_BUTTON_CLASS_NAME, - HIGHLIGHT_BUTTON_SIZE, - HIGHLIGHT_REMOVER_CLASS_NAME, - SR_ONLY, -} from '@/constants'; -import { Position } from '@/types'; import * as S from './style'; interface DragHighlightAddButtonProps { - position: Position; addHighlightByDrag: () => void; } -const DragHighlightAddButton = ({ addHighlightByDrag, position }: DragHighlightAddButtonProps) => { +const DragHighlightAddButton = ({ addHighlightByDrag }: DragHighlightAddButtonProps) => { return ( - - 하이라이트 추가 버튼 + - ); }; interface DragHighlightRemoveButtonProps { removeHighlightByDrag: () => void; - position: Position; } -const DragHighlightRemoveButton = ({ removeHighlightByDrag, position }: DragHighlightRemoveButtonProps) => { +const DragHighlightRemoveButton = ({ removeHighlightByDrag }: DragHighlightRemoveButtonProps) => { return ( - - 하이라이트 삭제 버튼 + ); @@ -52,21 +30,11 @@ const DragHighlightRemoveButton = ({ removeHighlightByDrag, position }: DragHigh interface LongPressHighlightRemoveButtonProps { removeHighlightByLongPress: () => void; - position: Position; } -const LongPressHighlightRemoveButton = ({ - removeHighlightByLongPress, - position, -}: LongPressHighlightRemoveButtonProps) => { +const LongPressHighlightRemoveButton = ({ removeHighlightByLongPress }: LongPressHighlightRemoveButtonProps) => { return ( - - 하이라이트 삭제 버튼 + ); diff --git a/frontend/src/components/highlight/components/HighlightButton/style.ts b/frontend/src/components/highlight/components/HighlightButton/style.ts index 660dfef32..24d597585 100644 --- a/frontend/src/components/highlight/components/HighlightButton/style.ts +++ b/frontend/src/components/highlight/components/HighlightButton/style.ts @@ -1,38 +1,22 @@ import styled from '@emotion/styled'; -import { HIGHLIGHT_BUTTON_SIZE } from '@/constants'; -import { Position } from '@/types'; - -export const Button = styled.button<{ $position: Position; $width: number }>` - position: absolute; - top: ${(props) => props.$position.top}; - left: ${(props) => props.$position.left}; +import { HIGHLIGHT_BUTTON_WIDTH } from '@/constants'; +export const Button = styled.button` display: flex; - gap: 0.8rem; + align-items: center; + justify-content: center; - width: ${(props) => `${props.$width / 10}rem`}; - height: ${() => `${HIGHLIGHT_BUTTON_SIZE.height / 10}rem`}; - padding: 0.5rem 0.8rem; + width: ${`${HIGHLIGHT_BUTTON_WIDTH / 10}rem`}; + padding: 0.5rem; - background-color: ${({ theme }) => theme.colors.white}; border-radius: ${({ theme }) => theme.borderRadius.basic}; - -webkit-box-shadow: 0 0 ${() => `${HIGHLIGHT_BUTTON_SIZE.shadow / 10}rem`} -0.2rem #343434b8; - box-shadow: 0 0 ${() => `${HIGHLIGHT_BUTTON_SIZE.shadow / 10}rem`} -0.2rem #343434b8; &:hover { - background-color: ${({ theme }) => theme.colors.palePurple}; + background-color: ${({ theme }) => theme.colors.lightPurple}; } `; - export const ButtonIcon = styled.img` - width: 1.5rem; - height: 1.5rem; -`; - -export const Color = styled.div` - width: 1.5rem; - height: 1.5rem; - background-color: ${({ theme }) => theme.colors.primary}; - border-radius: 50%; + width: 1.6rem; + height: 1.6rem; `; diff --git a/frontend/src/components/highlight/components/HighlightEditor/hooks/index.ts b/frontend/src/components/highlight/components/HighlightEditor/hooks/index.ts index 8d1e0da7a..0a69a9e2b 100644 --- a/frontend/src/components/highlight/components/HighlightEditor/hooks/index.ts +++ b/frontend/src/components/highlight/components/HighlightEditor/hooks/index.ts @@ -1,7 +1,7 @@ export { default as useHighlight } from './useHighlight'; -export { default as useDragHighlightButtonPosition } from './useDragHighlightButtonPosition'; +export { default as useDragHighlightButtonPosition } from './useDragHighlightPosition'; export { default as useCheckHighlight } from './useCheckHighlight'; -export { default as useLongPressHighlightButtonPosition } from './useLongPressHighlightButtonPosition'; +export { default as useLongPressHighlightButtonPosition } from './useLongPressHighlightPosition'; export { default as useLongPress } from './useLongPress'; export { default as useMutateHighlight } from './useMutateHighlight'; export { default as useEditableState } from './useEditableState'; diff --git a/frontend/src/components/highlight/components/HighlightEditor/hooks/useCheckHighlight.ts b/frontend/src/components/highlight/components/HighlightEditor/hooks/useCheckHighlight.ts index 36c2926ed..c8c0e447d 100644 --- a/frontend/src/components/highlight/components/HighlightEditor/hooks/useCheckHighlight.ts +++ b/frontend/src/components/highlight/components/HighlightEditor/hooks/useCheckHighlight.ts @@ -3,16 +3,28 @@ import { useState } from 'react'; import { HIGHLIGHT_SPAN_CLASS_NAME, SYNTAX_BASIC_CLASS_NAME } from '@/constants'; import { SelectionInfo } from '@/utils'; +export type HighlightArea = 'full' | 'partial' | 'none'; + const useCheckHighlight = () => { - const [isAddingHighlight, setIsAddingHighlight] = useState(false); + const [highlightArea, setHighlightArea] = useState('none'); const checkHighlight = (info: SelectionInfo) => { const selectedAllSpanList = getAllSpanInSelection(info.selection); - const isNoneHighlight = selectedAllSpanList.some((span) => !span.classList.contains(HIGHLIGHT_SPAN_CLASS_NAME)); + let highlightedSpanLength = 0; + + selectedAllSpanList.forEach((span) => { + if (span.classList.contains(HIGHLIGHT_SPAN_CLASS_NAME)) highlightedSpanLength += 1; + }); + + const newHighlightArea: HighlightArea = highlightedSpanLength + ? selectedAllSpanList.length === highlightedSpanLength + ? 'full' + : 'partial' + : 'none'; - setIsAddingHighlight(isNoneHighlight); + setHighlightArea(newHighlightArea); - return isNoneHighlight; + return newHighlightArea; }; const getAllSpanInSelection = (selection: Selection) => { @@ -23,7 +35,7 @@ const useCheckHighlight = () => { }; return { - isAddingHighlight, + highlightArea, checkHighlight, }; }; diff --git a/frontend/src/components/highlight/components/HighlightEditor/hooks/useDragHighlightButtonPosition.ts b/frontend/src/components/highlight/components/HighlightEditor/hooks/useDragHighlightPosition.ts similarity index 58% rename from frontend/src/components/highlight/components/HighlightEditor/hooks/useDragHighlightButtonPosition.ts rename to frontend/src/components/highlight/components/HighlightEditor/hooks/useDragHighlightPosition.ts index fc86c10c7..b212bf589 100644 --- a/frontend/src/components/highlight/components/HighlightEditor/hooks/useDragHighlightButtonPosition.ts +++ b/frontend/src/components/highlight/components/HighlightEditor/hooks/useDragHighlightPosition.ts @@ -1,29 +1,27 @@ -import { useLayoutEffect, useState } from 'react'; +import { useLayoutEffect } from 'react'; -import { GAP_WIDTH_SELECTION_AND_HIGHLIGHT_BUTTON, HIGHLIGHT_BUTTON_SIZE } from '@/constants'; +import { GAP_WIDTH_SELECTION_AND_HIGHLIGHT_BUTTON, HIGHLIGHT_MENU_STYLE_SIZE, HIGHLIGHT_MENU_WIDTH } from '@/constants'; import { Position } from '@/types'; import { isTouchDevice, SelectionInfo } from '@/utils'; -interface UseDragButtonPositionProps { +import { HighlightArea } from './useCheckHighlight'; + +interface UseDragHighlightPositionProps { isEditable: boolean; editorRef: React.RefObject; - hideLongPressHighlightButton: () => void; + updateHighlightMenuPosition: (position: Position | null) => void; } -export interface getDragHighlightButtonParams { +export interface getDragHighlightParams { selectionInfo: SelectionInfo; - isAddingHighlight: boolean; + highlightArea: HighlightArea; } -const useDragHighlightButtonPosition = ({ +const useDragHighlightPosition = ({ isEditable, editorRef, - hideLongPressHighlightButton, -}: UseDragButtonPositionProps) => { - const [dragHighlightButtonPosition, setDragHighlightButtonPosition] = useState(null); - - const hideDragHighlightButton = () => setDragHighlightButtonPosition(null); - + updateHighlightMenuPosition, +}: UseDragHighlightPositionProps) => { //위치 계산 interface GetRectsParams { selectionInfo: SelectionInfo; @@ -62,7 +60,7 @@ const useDragHighlightButtonPosition = ({ isForwardDrag: boolean, buttonWidth: number, ) => { - const { height: buttonHeight } = HIGHLIGHT_BUTTON_SIZE; + const { height: buttonHeight } = HIGHLIGHT_MENU_STYLE_SIZE; const isTouch = isTouchDevice(); //뷰포트 기준 위치 const rectLeft = isForwardDrag ? lastRect.right - (isTouch ? buttonWidth : 0) : lastRect.left; @@ -85,23 +83,33 @@ const useDragHighlightButtonPosition = ({ * @param editorRect editor DOMRect */ const checkOverflow = (rectLeft: number, rectTop: number, buttonWidth: number, editorRect: DOMRect) => { - const { shadow: shadowWidth, height: buttonHeight } = HIGHLIGHT_BUTTON_SIZE; - const isOverflowingHorizontally = editorRect.right < rectLeft + buttonWidth + shadowWidth; - const isOverflowingVertically = editorRect.bottom < rectTop + buttonHeight; + const { shadow: shadowWidth, height: buttonHeight } = HIGHLIGHT_MENU_STYLE_SIZE; + const buttonTotalHeight = buttonHeight + shadowWidth; + const buttonTotalWidth = buttonWidth + shadowWidth; + + const isOverflowingHorizontally = { + right: editorRect.right < rectLeft + buttonTotalWidth, + left: rectLeft - buttonTotalWidth < editorRect.left, + }; + const isOverflowingVertically = { + top: rectTop - buttonTotalHeight - GAP_WIDTH_SELECTION_AND_HIGHLIGHT_BUTTON <= editorRect.top, + bottom: editorRect.bottom <= rectTop + buttonTotalHeight + GAP_WIDTH_SELECTION_AND_HIGHLIGHT_BUTTON, + }; return { isOverflowingHorizontally, isOverflowingVertically }; }; - interface CalculateDragHighlightButtonPosition { + interface CalculateDragHighlightMenuPosition { leftOffsetFromEditor: number; topOffsetFromEditor: number; buttonWidth: number; - isOverflowingHorizontally: boolean; - isOverflowingVertically: boolean; + isOverflowingHorizontally: { left: boolean; right: boolean }; + isOverflowingVertically: { top: boolean; bottom: boolean }; editorRect: DOMRect; lastRect: DOMRect; } - const calculateDragHighlightButtonPosition = ({ + + const calculateDragHighlightMenuPosition = ({ leftOffsetFromEditor, topOffsetFromEditor, buttonWidth, @@ -109,34 +117,41 @@ const useDragHighlightButtonPosition = ({ isOverflowingVertically, editorRect, lastRect, - }: CalculateDragHighlightButtonPosition) => { - const { height: buttonHeight, shadow: shadowWidth } = HIGHLIGHT_BUTTON_SIZE; - - const left = isOverflowingHorizontally ? editorRect.width - buttonWidth - shadowWidth : leftOffsetFromEditor; - const top = isOverflowingVertically - ? topOffsetFromEditor - lastRect.height - GAP_WIDTH_SELECTION_AND_HIGHLIGHT_BUTTON * 2 - buttonHeight - : topOffsetFromEditor; + }: CalculateDragHighlightMenuPosition) => { + const { height: buttonHeight, shadow: shadowWidth } = HIGHLIGHT_MENU_STYLE_SIZE; + const buttonTotalHeight = buttonHeight + shadowWidth; + const buttonTotalWidth = buttonWidth + shadowWidth; + + let left = leftOffsetFromEditor; + let top = topOffsetFromEditor; + + // left 계산 + if (isOverflowingHorizontally.right) { + left = editorRect.width - buttonTotalWidth; + } + if (isOverflowingHorizontally.left) { + left = shadowWidth; + } + + // top 계산 + if (isOverflowingVertically.bottom) { + top = topOffsetFromEditor - lastRect.height - GAP_WIDTH_SELECTION_AND_HIGHLIGHT_BUTTON - buttonTotalHeight; + } + if (isOverflowingVertically.top) { + top = shadowWidth; + } return { left, top }; }; - const getButtonWidth = (isAddingHighlight: boolean) => { - const { basic: buttonBasicWidth, buttonWidthColor: addButtonWidth } = HIGHLIGHT_BUTTON_SIZE.width; - const buttonWidth = isAddingHighlight ? addButtonWidth : buttonBasicWidth; - - return { - buttonWidth, - }; - }; - - const getDragHighlightButtonPosition = ({ selectionInfo, isAddingHighlight }: getDragHighlightButtonParams) => { + const getDragHighlightPosition = ({ selectionInfo, highlightArea }: getDragHighlightParams) => { const { isForwardDrag } = selectionInfo; const rects = getRects({ selectionInfo, editorRef }); if (!rects) return; const { lastRect, editorRect } = rects; - const { buttonWidth } = getButtonWidth(isAddingHighlight); + const buttonWidth = HIGHLIGHT_MENU_WIDTH[highlightArea]; const { leftOffsetFromEditor, topOffsetFromEditor, rectLeft, rectTop } = calculateRectOffsets( lastRect, @@ -151,7 +166,7 @@ const useDragHighlightButtonPosition = ({ editorRect, ); - const { left, top } = calculateDragHighlightButtonPosition({ + const { left, top } = calculateDragHighlightMenuPosition({ leftOffsetFromEditor, topOffsetFromEditor, buttonWidth, @@ -169,25 +184,22 @@ const useDragHighlightButtonPosition = ({ return position; }; - const updateDragHighlightButtonPosition = ({ selectionInfo, isAddingHighlight }: getDragHighlightButtonParams) => { - const position = getDragHighlightButtonPosition({ selectionInfo, isAddingHighlight }); + const updateHighlightMenuPositionByDrag = ({ selectionInfo, highlightArea }: getDragHighlightParams) => { + const position = getDragHighlightPosition({ selectionInfo, highlightArea }); if (!position) return console.error('endPosition을 찾을 수 없어요.'); - setDragHighlightButtonPosition(position); - hideLongPressHighlightButton(); + updateHighlightMenuPosition(position); }; useLayoutEffect(() => { - if (!isEditable) hideDragHighlightButton(); + if (!isEditable) updateHighlightMenuPosition(null); }, [isEditable]); - useLayoutEffect(() => {}); - return { - dragHighlightButtonPosition, - hideDragHighlightButton, - updateDragHighlightButtonPosition, + updateHighlightMenuPositionByDrag, }; }; -export default useDragHighlightButtonPosition; +export default useDragHighlightPosition; + +export type UseDragHighlightPositionReturn = ReturnType; diff --git a/frontend/src/components/highlight/components/HighlightEditor/hooks/useEditableState.ts b/frontend/src/components/highlight/components/HighlightEditor/hooks/useEditableState.ts index 3dd242b6b..deead33fa 100644 --- a/frontend/src/components/highlight/components/HighlightEditor/hooks/useEditableState.ts +++ b/frontend/src/components/highlight/components/HighlightEditor/hooks/useEditableState.ts @@ -27,6 +27,9 @@ const useEditableState = () => { if (storageItem) setIsEditable(true); return () => { + if (sessionStorage.getItem(SESSION_STORAGE_KEY.isHighlightError)) { + return sessionStorage.removeItem(SESSION_STORAGE_KEY.isHighlightError); + } removeHighlightEditorStateFromStorage(); }; }, []); diff --git a/frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlight.ts b/frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlight.ts index bad65a09f..23acfebc3 100644 --- a/frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlight.ts +++ b/frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlight.ts @@ -1,7 +1,7 @@ import { useState } from 'react'; import { EDITOR_ANSWER_CLASS_NAME, HIGHLIGHT_SPAN_CLASS_NAME } from '@/constants'; -import { EditorAnswerMap, EditorLine, Highlight, ReviewAnswerResponseData } from '@/types'; +import { EditorAnswerMap, EditorLine, HighlightResponseData, ReviewAnswerResponseData } from '@/types'; import { getEndLineOffset, getStartLineOffset, @@ -12,28 +12,26 @@ import { SelectionInfo, } from '@/utils'; +import { UseLongPressHighlightPositionReturn } from './useLongPressHighlightPosition'; import useMutateHighlight from './useMutateHighlight'; -interface UseHighlightProps { +interface UseHighlightProps extends UseLongPressHighlightPositionReturn { questionId: number; answerList: ReviewAnswerResponseData[]; isEditable: boolean; - hideDragHighlightButton: () => void; - updateLongPressHighlightButtonPosition: (rect: DOMRect) => void; - hideLongPressHighlightButton: () => void; handleErrorModal: (isError: boolean) => void; + resetHighlightMenuPosition: () => void; } - interface RemovalTarget { answerId: number; lineIndex: number; highlightIndex: number; } -const findBlockHighlightListFromAnswer = (answerHighlightList: Highlight[], lineIndex: number) => { - return answerHighlightList.find((i) => i.lineIndex === lineIndex)?.rangeList || []; +const findBlockHighlightListFromAnswer = (answerHighlightList: HighlightResponseData[], lineIndex: number) => { + return answerHighlightList.find((i) => i.lineIndex === lineIndex)?.ranges || []; }; -const makeBlockListByText = (content: string, answerHighlightList: Highlight[]): EditorLine[] => { +const makeBlockListByText = (content: string, answerHighlightList: HighlightResponseData[]): EditorLine[] => { return content.split('\n').map((text, index) => ({ lineIndex: index, text, @@ -60,27 +58,29 @@ const useHighlight = ({ questionId, answerList, isEditable, - hideDragHighlightButton, - updateLongPressHighlightButtonPosition, - hideLongPressHighlightButton, + updateHighlightMenuPositionByLongPress, + resetHighlightMenuPosition, handleErrorModal, }: UseHighlightProps) => { const [editorAnswerMap, setEditorAnswerMap] = useState(makeInitialEditorAnswerMap(answerList)); // span 클릭 시, 제공되는 형광펜 삭제 기능 타겟 - const [removalTarget, setRemovalTarget] = useState(null); + const [longPressRemovalTarget, setLongPressRemovalTarget] = useState(null); + + const resetLongPressRemovalTarget = () => setLongPressRemovalTarget(null); const updateEditorAnswerMap = (newEditorAnswerMap: EditorAnswerMap) => setEditorAnswerMap(newEditorAnswerMap); - const resetHighlightButton = () => { + const resetHighlightMenu = () => { removeSelection(); - hideDragHighlightButton(); + resetHighlightMenuPosition(); + resetLongPressRemovalTarget(); }; const { mutate: mutateHighlight } = useMutateHighlight({ questionId, updateEditorAnswerMap, - resetHighlightButton, + resetHighlightMenu, handleErrorModal, }); @@ -135,9 +135,9 @@ const useHighlight = ({ if (!targetAnswer) return; const { lineList } = targetAnswer; - const newLineList = lineList.map((block) => ({ - ...block, - highlightList: [{ startIndex: 0, endIndex: block.text.length - 1 }], + const newLineList = lineList.map((line) => ({ + ...line, + highlightList: [{ startIndex: 0, endIndex: line.text.length - 1 }], })); newEditorAnswerMap.set(answerId, { ...targetAnswer, lineList: newLineList }); @@ -150,17 +150,17 @@ const useHighlight = ({ if (!targetAnswer) return; const { lineList } = targetAnswer; - const newLineList = lineList.map((block, index) => { - if (index > lineIndex) return block; + const newLineList = lineList.map((line, index) => { + if (index > lineIndex) return line; if (index < lineIndex) { return { - ...block, - highlightList: [{ startIndex: 0, endIndex: block.text.length - 1 }], + ...line, + highlightList: [{ startIndex: 0, endIndex: line.text.length - 1 }], }; } return getUpdatedBlockByHighlight({ - blockTextLength: block.text.length, + blockTextLength: line.text.length, lineIndex: index, startIndex: 0, endIndex: offset, @@ -185,14 +185,13 @@ const useHighlight = ({ if (!targetAnswer) return; - const newLineList: EditorLine[] = targetAnswer.lineList.map((block, index, array) => { - if (index < startLineIndex) return block; - if (index > endLineIndex) return block; + const newLineList: EditorLine[] = targetAnswer.lineList.map((line, index, array) => { + if (index < startLineIndex) return line; + if (index > endLineIndex) return line; if (index === startLineIndex) { - const { startIndex, endIndex } = getStartLineOffset(selectionInfo, block); - + const { startIndex, endIndex } = getStartLineOffset(selectionInfo, line); return getUpdatedBlockByHighlight({ - blockTextLength: block.text.length, + blockTextLength: line.text.length, lineIndex: index, startIndex, endIndex, @@ -204,7 +203,7 @@ const useHighlight = ({ const endIndex = getEndLineOffset(selectionInfo); return getUpdatedBlockByHighlight({ - blockTextLength: block.text.length, + blockTextLength: line.text.length, lineIndex: index, startIndex: 0, endIndex, @@ -212,8 +211,8 @@ const useHighlight = ({ }); } return { - ...block, - highlightList: [{ startIndex: 0, endIndex: block.text.length }], + ...line, + highlightList: [{ startIndex: 0, endIndex: line.text.length - 1 }], }; }); @@ -281,7 +280,6 @@ const useHighlight = ({ newEditorAnswerMap.set(answerId, { ...targetAnswer, lineList: newLineList }); return newEditorAnswerMap; }; - const removeMultipleAnswerHighlight = (selectionInfo: SelectionInfo) => { const { startAnswer, endAnswer } = selectionInfo; const newEditorAnswerMap = new Map(editorAnswerMap); @@ -351,8 +349,8 @@ const useHighlight = ({ const targetAnswer = newEditorAnswerMap.get(answerId); if (!targetAnswer) return; - const newLineList: EditorLine[] = targetAnswer.lineList.map((block) => ({ - ...block, + const newLineList: EditorLine[] = targetAnswer.lineList.map((line) => ({ + ...line, highlightList: [], })); newEditorAnswerMap.set(answerId, { ...targetAnswer, lineList: newLineList }); @@ -373,6 +371,7 @@ const useHighlight = ({ } return false; }; + const handleLongPressLine = (event: React.MouseEvent | React.TouchEvent) => { if (!isEditable) return; if (isSingleCharacterSelected()) return; @@ -395,19 +394,19 @@ const useHighlight = ({ const { highlightList } = targetAnswer.lineList[Number(lineIndex)]; const highlightIndex = highlightList.findIndex((i) => i.startIndex === Number(start) && i.endIndex === Number(end)); - setRemovalTarget({ + setLongPressRemovalTarget({ answerId: targetAnswer.answerId, lineIndex: Number(lineIndex), highlightIndex: Number(highlightIndex), }); - updateLongPressHighlightButtonPosition(rect); + updateHighlightMenuPositionByLongPress(rect); }; const removeHighlightByLongPress = async () => { - if (!removalTarget) return; + if (!longPressRemovalTarget) return; - const { answerId, lineIndex, highlightIndex } = removalTarget; + const { answerId, lineIndex, highlightIndex } = longPressRemovalTarget; const newEditorAnswerMap: EditorAnswerMap = new Map(editorAnswerMap); const targetAnswer = newEditorAnswerMap.get(answerId); @@ -424,7 +423,6 @@ const useHighlight = ({ newEditorAnswerMap.set(answerId, { ...targetAnswer, lineList: newLineList }); mutateHighlight(newEditorAnswerMap); - hideLongPressHighlightButton(); }; return { @@ -433,7 +431,8 @@ const useHighlight = ({ removeHighlightByDrag, handleLongPressLine, removeHighlightByLongPress, - removalTarget, + longPressRemovalTarget, + resetLongPressRemovalTarget, }; }; diff --git a/frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlightEventListener.ts b/frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlightEventListener.ts index 88b21dc82..deb7d6e14 100644 --- a/frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlightEventListener.ts +++ b/frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlightEventListener.ts @@ -1,16 +1,16 @@ import { useEffect } from 'react'; -import { HIGHLIGHT__TOGGLE_BUTTON_CLASS_NAME, HIGHLIGHT_REMOVER_CLASS_NAME } from '@/constants'; +import { HIGHLIGHT_MENU_CLASS_NAME } from '@/constants'; import { findSelectionInfo, isTouchDevice, SelectionInfo } from '@/utils'; -import { getDragHighlightButtonParams } from './useDragHighlightButtonPosition'; +import { HighlightArea } from './useCheckHighlight'; +import { UseDragHighlightPositionReturn } from './useDragHighlightPosition'; -interface UseHighlightEventListenerProps { +interface UseHighlightEventListenerProps extends UseDragHighlightPositionReturn { isEditable: boolean; - updateDragHighlightButtonPosition: ({ selectionInfo, isAddingHighlight }: getDragHighlightButtonParams) => void; - hideDragHighlightButton: () => void; - hideLongPressHighlightButton: () => void; - checkHighlight: (info: SelectionInfo) => boolean; + resetHighlightMenuPosition: () => void; + checkHighlight: (info: SelectionInfo) => HighlightArea; + resetLongPressRemovalTarget: () => void; } /** @@ -18,39 +18,39 @@ interface UseHighlightEventListenerProps { */ const useHighlightEventListener = ({ isEditable, - updateDragHighlightButtonPosition, - hideDragHighlightButton, - hideLongPressHighlightButton, + updateHighlightMenuPositionByDrag, + resetHighlightMenuPosition, checkHighlight, + resetLongPressRemovalTarget, }: UseHighlightEventListenerProps) => { - const hideHighlightButton = (e: MouseEvent | TouchEvent) => { + const hideHighlightMenu = (e: MouseEvent | TouchEvent) => { if (!isEditable) return; - const isInButton = (e.target as HTMLElement).closest(`.${HIGHLIGHT__TOGGLE_BUTTON_CLASS_NAME}`); - const isNotHighlightRemover = (e.target as HTMLElement).closest(`.${HIGHLIGHT_REMOVER_CLASS_NAME}`); - - if (!isInButton) hideDragHighlightButton(); - if (!isNotHighlightRemover) hideLongPressHighlightButton(); + const isInHighlightMenu = (e.target as HTMLElement).closest(`.${HIGHLIGHT_MENU_CLASS_NAME}`); + if (!isInHighlightMenu) { + resetHighlightMenuPosition(); + resetLongPressRemovalTarget(); + } }; - const showHighlightButton = () => { + const showHighlightMenu = () => { if (!isEditable) return; const selectionInfo = findSelectionInfo(); if (!selectionInfo) return; - const isAddingHighlight = checkHighlight(selectionInfo); - updateDragHighlightButtonPosition({ selectionInfo, isAddingHighlight }); + const highlightArea = checkHighlight(selectionInfo); + updateHighlightMenuPositionByDrag({ selectionInfo, highlightArea }); }; /** * document에 형광펜 이벤트 적용 */ const addHighlightEvent = () => { - document.addEventListener('mousedown', hideHighlightButton); - document.addEventListener('mouseup', showHighlightButton); + document.addEventListener('mousedown', hideHighlightMenu); + document.addEventListener('mouseup', showHighlightMenu); // NOTE: 터치가 가능한 기기에서는 touchstart, touchend 보다 selectionchange를 사용하는 게 오류가 없음 if (isTouchDevice()) { - document.addEventListener('selectionchange', showHighlightButton); + document.addEventListener('selectionchange', showHighlightMenu); document.addEventListener('contextmenu', hideContextMenuInTouch); } }; @@ -65,11 +65,11 @@ const useHighlightEventListener = ({ * document에 형광펜 이벤트 삭제 */ const removeHighlightEvent = () => { - document.removeEventListener('mouseup', showHighlightButton); - document.removeEventListener('mousedown', hideHighlightButton); + document.removeEventListener('mouseup', showHighlightMenu); + document.removeEventListener('mousedown', hideHighlightMenu); if (isTouchDevice()) { document.removeEventListener('contextmenu', hideContextMenuInTouch); - document.removeEventListener('selectionChange', showHighlightButton); + document.removeEventListener('selectionChange', showHighlightMenu); } }; diff --git a/frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlightMenuPosition/index.ts b/frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlightMenuPosition/index.ts new file mode 100644 index 000000000..7aaa6f263 --- /dev/null +++ b/frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlightMenuPosition/index.ts @@ -0,0 +1,42 @@ +import { useState } from 'react'; + +import { Position } from '@/types'; + +import useDragHighlightPosition from '../useDragHighlightPosition'; +import useLongPressHighlightPosition from '../useLongPressHighlightPosition'; + +interface UseHighlightMenuPositionProps { + isEditable: boolean; + editorRef: React.RefObject; +} + +const useHighlightMenuPosition = ({ isEditable, editorRef }: UseHighlightMenuPositionProps) => { + const [menuPosition, setMenuPosition] = useState(null); + + const updateHighlightMenuPosition = (position: Position | null) => setMenuPosition(position); + + const resetHighlightMenuPosition = () => { + setMenuPosition(null); + }; + + const { updateHighlightMenuPositionByDrag } = useDragHighlightPosition({ + isEditable, + editorRef, + updateHighlightMenuPosition, + }); + + const { updateHighlightMenuPositionByLongPress } = useLongPressHighlightPosition({ + isEditable, + editorRef, + updateHighlightMenuPosition, + }); + + return { + menuPosition, + updateHighlightMenuPositionByDrag, + updateHighlightMenuPositionByLongPress, + resetHighlightMenuPosition, + }; +}; + +export default useHighlightMenuPosition; diff --git a/frontend/src/components/highlight/components/HighlightEditor/hooks/useLongPressHighlightButtonPosition.ts b/frontend/src/components/highlight/components/HighlightEditor/hooks/useLongPressHighlightButtonPosition.ts deleted file mode 100644 index 6d4b97f26..000000000 --- a/frontend/src/components/highlight/components/HighlightEditor/hooks/useLongPressHighlightButtonPosition.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { useLayoutEffect, useState } from 'react'; - -import { GAP_WIDTH_SELECTION_AND_HIGHLIGHT_BUTTON, HIGHLIGHT_BUTTON_SIZE } from '@/constants'; -import { Position } from '@/types'; - -interface UseLongPressHighlightButtonPositionProps { - isEditable: boolean; - editorRef: React.RefObject; -} -const useLongPressHighlightButtonPosition = ({ isEditable, editorRef }: UseLongPressHighlightButtonPositionProps) => { - const [longPressHighlightButtonPosition, setLongPressHighlightButtonPosition] = useState(null); - - const updateLongPressHighlightButtonPosition = (rect: DOMRect) => { - const editorRect = editorRef.current?.getClientRects()[0]; - if (!editorRect) return; - - const top = rect.bottom + GAP_WIDTH_SELECTION_AND_HIGHLIGHT_BUTTON; - const left = rect.left + rect.width / 2 - editorRect.left; - - const buttonTotalHeight = HIGHLIGHT_BUTTON_SIZE.height + HIGHLIGHT_BUTTON_SIZE.shadow; - const isOverflowingVertically = - rect.bottom + GAP_WIDTH_SELECTION_AND_HIGHLIGHT_BUTTON + buttonTotalHeight >= editorRect.bottom; - - const topOffsetFromParent = - (isOverflowingVertically ? rect.top - GAP_WIDTH_SELECTION_AND_HIGHLIGHT_BUTTON - buttonTotalHeight : top) - - editorRect.top; - - const leftOffsetFromParent = left; - - setLongPressHighlightButtonPosition({ - top: ` - ${topOffsetFromParent / 10}rem`, - left: `${leftOffsetFromParent / 10}rem`, - }); - }; - - const hideLongPressHighlightButton = () => setLongPressHighlightButtonPosition(null); - - useLayoutEffect(() => { - if (!isEditable) hideLongPressHighlightButton(); - }, [isEditable]); - - return { - longPressHighlightButtonPosition, - updateLongPressHighlightButtonPosition, - hideLongPressHighlightButton, - }; -}; - -export default useLongPressHighlightButtonPosition; diff --git a/frontend/src/components/highlight/components/HighlightEditor/hooks/useLongPressHighlightPosition.ts b/frontend/src/components/highlight/components/HighlightEditor/hooks/useLongPressHighlightPosition.ts new file mode 100644 index 000000000..c3ebb4e16 --- /dev/null +++ b/frontend/src/components/highlight/components/HighlightEditor/hooks/useLongPressHighlightPosition.ts @@ -0,0 +1,80 @@ +import { useLayoutEffect } from 'react'; + +import { GAP_WIDTH_SELECTION_AND_HIGHLIGHT_BUTTON, HIGHLIGHT_MENU_STYLE_SIZE } from '@/constants'; +import { Position } from '@/types'; + +import { useLongPressHighlightButtonPosition } from '.'; + +interface UseLongPressHighlightPositionProps { + isEditable: boolean; + editorRef: React.RefObject; + updateHighlightMenuPosition: (position: Position | null) => void; +} +const useLongPressHighlightPosition = ({ + isEditable, + editorRef, + updateHighlightMenuPosition, +}: UseLongPressHighlightPositionProps) => { + const isOverflowingEditor = (longPressTargetRect: DOMRect, editorRect: DOMRect) => { + const buttonTotalHeight = HIGHLIGHT_MENU_STYLE_SIZE.height + HIGHLIGHT_MENU_STYLE_SIZE.shadow; + + const isOverflowingVertically = { + top: longPressTargetRect.top - GAP_WIDTH_SELECTION_AND_HIGHLIGHT_BUTTON - buttonTotalHeight <= editorRect.top, + bottom: + longPressTargetRect.bottom + GAP_WIDTH_SELECTION_AND_HIGHLIGHT_BUTTON + buttonTotalHeight >= editorRect.bottom, + }; + + return { isOverflowingVertically }; + }; + + const calculateHighlightMenuPositionByLongPress = (longPressTargetRect: DOMRect, editorRect: DOMRect) => { + const buttonRectTop = longPressTargetRect.bottom + GAP_WIDTH_SELECTION_AND_HIGHLIGHT_BUTTON; + const buttonTotalHeight = HIGHLIGHT_MENU_STYLE_SIZE.height + HIGHLIGHT_MENU_STYLE_SIZE.shadow; + + const { isOverflowingVertically } = isOverflowingEditor(longPressTargetRect, editorRect); + + let topOffsetFromParent = buttonRectTop - editorRect.top; + + if (isOverflowingVertically.bottom) { + topOffsetFromParent = + longPressTargetRect.top - GAP_WIDTH_SELECTION_AND_HIGHLIGHT_BUTTON - buttonTotalHeight - editorRect.top; + } + + if (isOverflowingVertically.top) { + topOffsetFromParent = HIGHLIGHT_MENU_STYLE_SIZE.shadow; + } + + // 하이아리트 영역의 중간에 위치 + const leftOffsetFromParent = longPressTargetRect.left + longPressTargetRect.width / 2 - editorRect.left; + + return { topOffsetFromParent, leftOffsetFromParent }; + }; + + const updateHighlightMenuPositionByLongPress = (longPressTargetRect: DOMRect) => { + const editorRect = editorRef.current?.getClientRects()[0]; + if (!editorRect) return; + + const { topOffsetFromParent, leftOffsetFromParent } = calculateHighlightMenuPositionByLongPress( + longPressTargetRect, + editorRect, + ); + + updateHighlightMenuPosition({ + top: ` + ${topOffsetFromParent / 10}rem`, + left: `${leftOffsetFromParent / 10}rem`, + }); + }; + + useLayoutEffect(() => { + if (!isEditable) updateHighlightMenuPosition(null); + }, [isEditable]); + + return { + updateHighlightMenuPositionByLongPress, + }; +}; + +export default useLongPressHighlightPosition; + +export type UseLongPressHighlightPositionReturn = ReturnType; diff --git a/frontend/src/components/highlight/components/HighlightEditor/hooks/useMutateHighlight/index.ts b/frontend/src/components/highlight/components/HighlightEditor/hooks/useMutateHighlight/index.ts index 167d16005..79951c40e 100644 --- a/frontend/src/components/highlight/components/HighlightEditor/hooks/useMutateHighlight/index.ts +++ b/frontend/src/components/highlight/components/HighlightEditor/hooks/useMutateHighlight/index.ts @@ -1,12 +1,13 @@ import { useMutation } from '@tanstack/react-query'; import { postHighlight } from '@/apis/highlight'; +import { SESSION_STORAGE_KEY } from '@/constants'; import { EditorAnswerMap } from '@/types'; export interface UseMutateHighlightProps { questionId: number; updateEditorAnswerMap: (editorAnswerMap: EditorAnswerMap) => void; - resetHighlightButton: () => void; + resetHighlightMenu: () => void; handleErrorModal: (isError: boolean) => void; } @@ -14,20 +15,23 @@ const useMutateHighlight = ({ questionId, handleErrorModal, updateEditorAnswerMap, - resetHighlightButton, + resetHighlightMenu, }: UseMutateHighlightProps) => { const mutation = useMutation({ mutationFn: (newEditorAnswerMap: EditorAnswerMap) => postHighlight(newEditorAnswerMap, questionId), onSuccess: (_, variables: EditorAnswerMap) => { updateEditorAnswerMap(variables); - resetHighlightButton(); + resetHighlightMenu(); // 토스트 모달 지우기 handleErrorModal(false); + sessionStorage.removeItem(SESSION_STORAGE_KEY.isHighlightError); }, onError: (error) => { //토스트 모달 띄움 handleErrorModal(true); + // fallback 실행으로 인한,isEditable 상태 초기화 막음 + sessionStorage.setItem(SESSION_STORAGE_KEY.isHighlightError, 'true'); }, }); diff --git a/frontend/src/components/highlight/components/HighlightEditor/hooks/useMutateHighlight/test.ts b/frontend/src/components/highlight/components/HighlightEditor/hooks/useMutateHighlight/test.ts index a89391aa8..e3e4572e9 100644 --- a/frontend/src/components/highlight/components/HighlightEditor/hooks/useMutateHighlight/test.ts +++ b/frontend/src/components/highlight/components/HighlightEditor/hooks/useMutateHighlight/test.ts @@ -23,7 +23,7 @@ describe('하이라이트 요청 테스트', () => { const props: UseMutateHighlightProps = { questionId: QUESTION_ID, updateEditorAnswerMap: () => {}, - resetHighlightButton: () => {}, + resetHighlightMenu: () => {}, handleErrorModal: () => {}, }; diff --git a/frontend/src/components/highlight/components/HighlightEditor/index.tsx b/frontend/src/components/highlight/components/HighlightEditor/index.tsx index ce3ae5955..fbec2c3da 100644 --- a/frontend/src/components/highlight/components/HighlightEditor/index.tsx +++ b/frontend/src/components/highlight/components/HighlightEditor/index.tsx @@ -1,36 +1,17 @@ import { useRef } from 'react'; -import GrayHighlighterIcon from '@/assets/grayHighlighter.svg'; -import PrimaryHighlighterIcon from '@/assets/primaryHighlighter.svg'; import { EDITOR_ANSWER_CLASS_NAME, EDITOR_LINE_CLASS_NAME } from '@/constants'; import { ReviewAnswerResponseData } from '@/types'; -import DragHighlightButtonContainer from '../DragHighlightButtonContainer'; import EditorLineBlock from '../EditorLineBlock'; import EditSwitchButton from '../EditSwitchButton'; -import LongPressHighlightButtonWrapper from '../LongPressHighlightButtonWrapper'; +import HighlightMenu from '../HighlightMenu'; +import Tooltip from '../Tooltip'; -import { - useDragHighlightButtonPosition, - useHighlight, - useCheckHighlight, - useLongPressHighlightButtonPosition, - useLongPress, - useEditableState, - useHighlightEventListener, -} from './hooks'; +import { useHighlight, useCheckHighlight, useLongPress, useEditableState, useHighlightEventListener } from './hooks'; +import useHighlightMenuPosition from './hooks/useHighlightMenuPosition'; import * as S from './style'; -const MODE_ICON = { - on: { - icon: PrimaryHighlighterIcon, - alt: '형광펜 기능 켜짐', - }, - off: { - icon: GrayHighlighterIcon, - alt: '형광펜 기능 꺼짐', - }, -}; export interface HighlightEditorProps { questionId: number; answerList: ReviewAnswerResponseData[]; @@ -42,35 +23,32 @@ const HighlightEditor = ({ questionId, answerList, handleErrorModal }: Highlight const { isEditable, handleEditToggleButton } = useEditableState(); - const { isAddingHighlight, checkHighlight } = useCheckHighlight(); - - const { longPressHighlightButtonPosition, hideLongPressHighlightButton, updateLongPressHighlightButtonPosition } = - useLongPressHighlightButtonPosition({ - isEditable, - editorRef, - }); + const { highlightArea, checkHighlight } = useCheckHighlight(); - const { dragHighlightButtonPosition, hideDragHighlightButton, updateDragHighlightButtonPosition } = - useDragHighlightButtonPosition({ - isEditable, - editorRef, - hideLongPressHighlightButton, - }); + const { + menuPosition, + updateHighlightMenuPositionByDrag, + updateHighlightMenuPositionByLongPress, + resetHighlightMenuPosition, + } = useHighlightMenuPosition({ + editorRef, + isEditable, + }); const { editorAnswerMap, + longPressRemovalTarget, addHighlightByDrag, removeHighlightByDrag, handleLongPressLine, removeHighlightByLongPress, - removalTarget, + resetLongPressRemovalTarget, } = useHighlight({ questionId, answerList, isEditable, - hideDragHighlightButton, - hideLongPressHighlightButton, - updateLongPressHighlightButtonPosition, + resetHighlightMenuPosition, + updateHighlightMenuPositionByLongPress, handleErrorModal, }); @@ -78,20 +56,17 @@ const HighlightEditor = ({ questionId, answerList, handleErrorModal }: Highlight useHighlightEventListener({ isEditable, - updateDragHighlightButtonPosition, - hideDragHighlightButton, - hideLongPressHighlightButton, + updateHighlightMenuPositionByDrag, + resetHighlightMenuPosition, + resetLongPressRemovalTarget, checkHighlight, }); return ( - 형광펜 - + 형광펜 + @@ -111,18 +86,13 @@ const HighlightEditor = ({ questionId, answerList, handleErrorModal }: Highlight ))} - - {isEditable && dragHighlightButtonPosition && ( - - )} - {isEditable && removalTarget && longPressHighlightButtonPosition && ( - )} diff --git a/frontend/src/components/highlight/components/HighlightEditor/style.ts b/frontend/src/components/highlight/components/HighlightEditor/style.ts index c3593359d..5e5249fd5 100644 --- a/frontend/src/components/highlight/components/HighlightEditor/style.ts +++ b/frontend/src/components/highlight/components/HighlightEditor/style.ts @@ -25,9 +25,8 @@ export const SwitchModIcon = styled.img` height: 1.6rem; `; -export const HighlightText = styled.span<{ $isEditable: boolean }>` +export const HighlightText = styled.span` display: inline-block; - color: ${({ $isEditable, theme }) => ($isEditable ? theme.colors.primary : theme.colors.gray)}; `; export const AnswerList = styled.ul` @@ -37,8 +36,14 @@ export const AnswerList = styled.ul` export const AnswerListItem = styled.li` margin-bottom: 1rem; - margin-left: 2rem; + margin-left: 0.8rem; &::marker { margin: 0; } `; + +export const Marker = styled.img` + width: 1rem; + height: 1rem; + margin-top: 0.5rem; +`; diff --git a/frontend/src/components/highlight/components/HighlightMenu/index.tsx b/frontend/src/components/highlight/components/HighlightMenu/index.tsx new file mode 100644 index 000000000..eab7ae1e9 --- /dev/null +++ b/frontend/src/components/highlight/components/HighlightMenu/index.tsx @@ -0,0 +1,45 @@ +import { HIGHLIGHT_MENU_CLASS_NAME, HIGHLIGHT_MENU_WIDTH } from '@/constants'; +import { Position } from '@/types'; + +import HighlightButton from '../HighlightButton'; +import { HighlightArea } from '../HighlightEditor/hooks/useCheckHighlight'; + +import * as S from './style'; + +interface HighlightMenuProps { + position: Position; + highlightArea: HighlightArea; + isOpenLongPressRemove: boolean; + addHighlightByDrag: () => void; + removeHighlightByDrag: () => void; + removeHighlightByLongPress: () => void; +} + +const HighlightMenu = ({ + position, + highlightArea, + isOpenLongPressRemove, + addHighlightByDrag, + removeHighlightByDrag, + removeHighlightByLongPress, +}: HighlightMenuProps) => { + const menuStyleWidth = HIGHLIGHT_MENU_WIDTH[isOpenLongPressRemove ? 'longPress' : highlightArea]; + + return ( + + {isOpenLongPressRemove && ( + + )} + {!isOpenLongPressRemove && ( + <> + {highlightArea !== 'full' && } + {highlightArea !== 'none' && ( + + )} + + )} + + ); +}; + +export default HighlightMenu; diff --git a/frontend/src/components/highlight/components/HighlightMenu/style.ts b/frontend/src/components/highlight/components/HighlightMenu/style.ts new file mode 100644 index 000000000..118e6fc84 --- /dev/null +++ b/frontend/src/components/highlight/components/HighlightMenu/style.ts @@ -0,0 +1,22 @@ +import styled from '@emotion/styled'; + +import { HIGHLIGHT_MENU_STYLE_SIZE } from '@/constants'; +import { Position } from '@/types'; + +export const Menu = styled.div<{ $position: Position; $width: number }>` + position: absolute; + top: ${(props) => props.$position.top}; + left: ${(props) => props.$position.left}; + + display: flex; + justify-content: space-between; + + width: ${(props) => `${props.$width / 10}rem`}; + height: ${`${HIGHLIGHT_MENU_STYLE_SIZE.height / 10}rem`}; + padding: 0.5rem 0.8rem; + + background-color: ${({ theme }) => theme.colors.white}; + border-radius: ${({ theme }) => theme.borderRadius.basic}; + -webkit-box-shadow: 0 0 ${`${HIGHLIGHT_MENU_STYLE_SIZE.shadow / 10}rem`} -0.2rem #343434b8; + box-shadow: 0 0 ${`${HIGHLIGHT_MENU_STYLE_SIZE.shadow / 10}rem`} -0.2rem #343434b8; +`; diff --git a/frontend/src/components/highlight/components/LongPressHighlightButtonWrapper/index.tsx b/frontend/src/components/highlight/components/LongPressHighlightButtonWrapper/index.tsx deleted file mode 100644 index c30d447c0..000000000 --- a/frontend/src/components/highlight/components/LongPressHighlightButtonWrapper/index.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { Position } from '@/types'; - -import HighlightButton from '../HighlightButton'; - -interface LongPressHighlightButtonWrapperProps { - buttonPosition: Position; - removeHighlightByLongPress: () => void; -} -/** - * 하이라이트 된 span 태그 클릭 시, 해당 하이라이트를 삭제할 수 있는 버튼을 띄우는 컴포넌트 - */ -const LongPressHighlightButtonWrapper = ({ - buttonPosition, - removeHighlightByLongPress, -}: LongPressHighlightButtonWrapperProps) => { - return ( - - ); -}; - -export default LongPressHighlightButtonWrapper; diff --git a/frontend/src/components/highlight/components/Syntax/style.ts b/frontend/src/components/highlight/components/Syntax/style.ts index 70de7fb2e..934bd83d4 100644 --- a/frontend/src/components/highlight/components/Syntax/style.ts +++ b/frontend/src/components/highlight/components/Syntax/style.ts @@ -4,8 +4,8 @@ interface SyntaxProps { $isHighlight: boolean; } export const Syntax = styled.span` - cursor: ${({$isHighlight})=> $isHighlight? 'pointer' :'auto'}; + cursor: ${({ $isHighlight }) => ($isHighlight ? 'pointer' : 'auto')}; line-height: 1.5; color: ${(props) => (props.$isHighlight ? props.theme.colors.white : 'inherit')}; - background-color: ${(props) => (props.$isHighlight ? props.theme.colors.primary : 'transparent')} + background-color: ${(props) => (props.$isHighlight ? props.theme.colors.primary : 'transparent')}; `; diff --git a/frontend/src/components/highlight/components/Tooltip/index.tsx b/frontend/src/components/highlight/components/Tooltip/index.tsx new file mode 100644 index 000000000..633b7e33c --- /dev/null +++ b/frontend/src/components/highlight/components/Tooltip/index.tsx @@ -0,0 +1,32 @@ +import { useState } from 'react'; + +import HelperIcon from '@/assets/helper.svg'; +import { isTouchDevice } from '@/utils'; + +import * as S from './style'; + +const Tooltip = () => { + const [isOpenMessage, setIsOpenMessage] = useState(false); + return ( + setIsOpenMessage(true)} onMouseOut={() => setIsOpenMessage(false)}> + + {isOpenMessage && ( + + {isTouchDevice() ? ( + <> + 글자를 선택해 형광펜을 적용하거나 삭제할 수 있어요 + 형광펜이 적용된 영역은 슬라이드 동작으로 삭제할 수 있어요 + + ) : ( + <> + 드래그하여 형광펜을 적용하거나 삭제할 수 있어요 + 형광펜이 적용된 영역은 길게 눌러 삭제할 수 있어요 + + )} + + )} + + ); +}; + +export default Tooltip; diff --git a/frontend/src/components/highlight/components/Tooltip/style.ts b/frontend/src/components/highlight/components/Tooltip/style.ts new file mode 100644 index 000000000..13584028d --- /dev/null +++ b/frontend/src/components/highlight/components/Tooltip/style.ts @@ -0,0 +1,55 @@ +import styled from '@emotion/styled'; + +export const TooltipButton = styled.button` + position: relative; +`; +export const HelperIcon = styled.img` + width: 1.6rem; + height: 1.6rem; +`; + +export const Message = styled.aside` + position: absolute; + /* HelperIcon과 말풍선 삼각형 높이 */ + top: calc(1.6rem * 2); + right: -4.6rem; + + display: flex; + flex-direction: column; + gap: 1rem; + align-items: start; + + width: max-content; + padding: 1.6rem 1.4rem; + + font-size: ${({ theme }) => theme.fontSize.small}; + + background-color: ${({ theme }) => theme.colors.palePurple}; + border: 1px solid ${({ theme }) => theme.colors.lightPurple}; + border-radius: ${({ theme }) => theme.borderRadius.basic}; + box-shadow: 0px 2px 5px 1px #dbdbdb; + + &:before { + content: ''; + + position: absolute; + top: -0.5rem; + right: 3.6rem; + transform: translate(0%, -50%); + + border-right: 1.6rem solid transparent; + border-bottom: 1.6rem solid ${({ theme }) => theme.colors.palePurple}; + border-left: 1.6rem solid transparent; + } + + @media screen and (max-width: 500px) { + /* 2rem: 상위 부모 요소의 padding 값 */ + max-width: calc(90vw - 2rem * 3); + padding: 1.6rem 1rem; + font-size: ${({ theme }) => theme.fontSize.small}; + } +`; + +export const Text = styled.p` + text-align: left; +`; diff --git a/frontend/src/constants/highlight.ts b/frontend/src/constants/highlight.ts index c2497f0ff..40f880e03 100644 --- a/frontend/src/constants/highlight.ts +++ b/frontend/src/constants/highlight.ts @@ -1,16 +1,22 @@ export const EDITOR_LINE_CLASS_NAME = 'editor__line'; +import { HighlightArea } from '@/components/highlight/components/HighlightEditor/hooks/useCheckHighlight'; export const EDITOR_ANSWER_CLASS_NAME = 'editor__answer'; -export const HIGHLIGHT__TOGGLE_BUTTON_CLASS_NAME = 'highlight__toggle-button'; +export const HIGHLIGHT_MENU_CLASS_NAME = 'editor__menu-highlight'; export const HIGHLIGHT_SPAN_CLASS_NAME = 'highlighted'; export const SYNTAX_BASIC_CLASS_NAME = 'syntax'; -export const HIGHLIGHT_REMOVER_CLASS_NAME = 'highlight__remover-button'; // 버튼 관련 -export const HIGHLIGHT_BUTTON_SIZE = { - height: 25, - width: { - buttonWidthColor: 52, - basic: 31, - }, +export const HIGHLIGHT_MENU_STYLE_SIZE = { + height: 30, shadow: 10, }; +export const HIGHLIGHT_BUTTON_WIDTH = 42; export const GAP_WIDTH_SELECTION_AND_HIGHLIGHT_BUTTON = 5; + +export const HIGHLIGHT_MENU_WIDTH: { [key in HighlightArea | 'longPress']: number } = (() => { + return { + partial: HIGHLIGHT_BUTTON_WIDTH * 2, + none: HIGHLIGHT_BUTTON_WIDTH, + full: HIGHLIGHT_BUTTON_WIDTH, + longPress: HIGHLIGHT_BUTTON_WIDTH, + }; +})(); diff --git a/frontend/src/constants/index.ts b/frontend/src/constants/index.ts index 2d1492954..3238057ed 100644 --- a/frontend/src/constants/index.ts +++ b/frontend/src/constants/index.ts @@ -4,5 +4,4 @@ export * from './review'; export * from './queryKey'; export * from './routerParam'; export * from './highlight'; -export * from './screenReader'; export * from './sessionStorageKey'; diff --git a/frontend/src/constants/screenReader.ts b/frontend/src/constants/screenReader.ts deleted file mode 100644 index f33ea0a77..000000000 --- a/frontend/src/constants/screenReader.ts +++ /dev/null @@ -1 +0,0 @@ -export const SR_ONLY = 'sr-only'; diff --git a/frontend/src/constants/sessionStorageKey.ts b/frontend/src/constants/sessionStorageKey.ts index c3d462ca4..e6fb46f51 100644 --- a/frontend/src/constants/sessionStorageKey.ts +++ b/frontend/src/constants/sessionStorageKey.ts @@ -1,3 +1,4 @@ export const SESSION_STORAGE_KEY = { isHighlightEditable: 'isHighlightEditable', + isHighlightError: 'isHighlightError', }; diff --git a/frontend/src/mocks/handlers/highlight.ts b/frontend/src/mocks/handlers/highlight.ts index a2a46d426..1171c3c29 100644 --- a/frontend/src/mocks/handlers/highlight.ts +++ b/frontend/src/mocks/handlers/highlight.ts @@ -6,7 +6,7 @@ import { authorizeWithCookie } from './cookies'; const postMockHighlight = () => http.post(endPoint.postingHighlight, ({ cookies }) => { - return authorizeWithCookie(cookies, () => HttpResponse.json({ error: 'error' }, { status: 200 })); + return authorizeWithCookie(cookies, () => HttpResponse.json({ status: 200 })); }); const highlightHandler = [postMockHighlight()]; diff --git a/frontend/src/mocks/mockData/reviewCollection.ts b/frontend/src/mocks/mockData/reviewCollection.ts index f28f31b52..57c096c05 100644 --- a/frontend/src/mocks/mockData/reviewCollection.ts +++ b/frontend/src/mocks/mockData/reviewCollection.ts @@ -42,14 +42,25 @@ export const GROUPED_REVIEWS_MOCK_DATA: GroupedReviews = { { id: 1, content: - '장의 시작부분은 짧고 직접적이며, 뒤따라 나올 복잡한 정보를 어떻게 해석해야 할 것인지 프레임을 짜주는 역할을 해야 한다. 그러면 아무리 긴 문장이라도 쉽게 읽힌다.', - highlights: [], + '장의 시작부분은 짧고 직접적이며, 뒤따라 나올 복잡한 정보를 어떻게 해석해야 할 것인지 프레임을 짜주는 역할을 해야 한다. 그러면 아무리 긴 문장이라도 쉽게 읽힌다.\n프레임을 짜주는 역할을 해야 한다. 그러면 아무리 긴 문장이라도 쉽게 읽힌다.\n프레임을 짜주는 역할을 해야 한다. 그러면 아무리 긴 문장이라도 쉽게 읽힌다.', + highlights: [{ lineIndex: 0, ranges: [{ startIndex: 0, endIndex: 0 }] }], }, { id: 2, content: - '고액공제건강보험과 건강저축계좌를 만들어 노동자와 고용주가 세금공제를 받을 수 있도록 하면 결과적으로 노동자의 의료보험 부담이 커진다.', - highlights: [], + 'http://localhost:3000/user/review-zone/5WkYQLqW1http://localhost:3000/user/review-zone/5WkYQLqW2http://localhost:3000/user/review-zone/5WkYQLqW3http://localhost:3000/user/review-zone/5WkYQLqW4http://localhost:3000/user/review-zone/5WkYQLqW5http://localhost:3000/user/review-zone/5WkYQLqW6http://localhost:3000/user/review-zone/5WkYQLqW7http://localhost:3000/user/review-zone/5WkYQLqW8http://localhost:3000/user/review-zone/5WkYQLqW9http://localhost:3000/user/review-zone/5WkYQLqW10', + highlights: [ + { + lineIndex: 0, + ranges: [ + { startIndex: 17, endIndex: 20 }, + { startIndex: 64, endIndex: 67 }, + { startIndex: 205, endIndex: 208 }, + { startIndex: 252, endIndex: 255 }, + { startIndex: 346, endIndex: 349 }, + ], + }, + ], }, { id: 3, diff --git a/frontend/src/styles/globalStyles.ts b/frontend/src/styles/globalStyles.ts index 20892810e..c316123fc 100644 --- a/frontend/src/styles/globalStyles.ts +++ b/frontend/src/styles/globalStyles.ts @@ -49,12 +49,6 @@ const globalStyles = (theme: Theme) => css` background: transparent; } } - - .sr-only { - position: fixed; - top: -999rem; - left: -999rem; - } `; export default globalStyles; diff --git a/frontend/src/types/highlight.ts b/frontend/src/types/highlight.ts index e44049bb6..c94deaafc 100644 --- a/frontend/src/types/highlight.ts +++ b/frontend/src/types/highlight.ts @@ -8,21 +8,16 @@ export interface HighlightRange { // NOTE: 서버에서는 하이라이트가 적용된 문장에 대한 하이라이트 정보만 보내주지만, 클라이언트는 뷰에 하이라이트 적용여부를 보여주는 편의성을 위핸 모든 문장 index를 가지지만, highlightList가 빈배열이면 하이아리트 적용이 안되어있고 빈배열이 아니면 하이라이트가 있습니다 -// 서버에서 내려주고, 받는 데이터 타입 -/** - * 하이라이트가 적용된 block(=문장)의 index, 하이라이트 범위 배열을 가진 타입 - */ -export interface Highlight { - lineIndex: number; // 하이라이트가 적용된 문장 index - // 서버에서는 ranges로 줌 - rangeList: HighlightRange[]; +// 서버에서 보내주는 리뷰 모아보기 데이터 속 하이라이트 타입 +export interface HighlightResponseData { + lineIndex: number; + ranges: HighlightRange[]; } - // 서버에서 보내주는 리뷰 모아보기 데이터 export interface ReviewAnswerResponseData { id: number; content: string; - highlights: Highlight[]; + highlights: HighlightResponseData[]; } /** diff --git a/frontend/src/utils/highlight/highlighList.ts b/frontend/src/utils/highlight/highlighList.ts index b20dd63c4..719225b36 100644 --- a/frontend/src/utils/highlight/highlighList.ts +++ b/frontend/src/utils/highlight/highlighList.ts @@ -42,6 +42,10 @@ const makeHighlightListByConsecutiveOnes = (array: string[]) => { } } + if (startIndex !== -1) { + result.push({ startIndex, endIndex: array.length - 1 }); + } + return result; }; @@ -76,11 +80,11 @@ export const getUpdatedBlockByHighlight = ({ lineList, }: GetUpdatedBlockByHighlightParams) => { const newHighlight: HighlightRange = { startIndex, endIndex }; - const block = lineList[lineIndex]; - const { highlightList } = block; + const line = lineList[lineIndex]; + const { highlightList } = line; return { - ...block, + ...line, highlightList: mergeHighlightList({ blockTextLength, highlightList, newHighlight }), }; }; diff --git a/frontend/src/utils/highlight/selection.ts b/frontend/src/utils/highlight/selection.ts index cdcd016d5..16aa12ea9 100644 --- a/frontend/src/utils/highlight/selection.ts +++ b/frontend/src/utils/highlight/selection.ts @@ -4,7 +4,7 @@ import { EditorLine } from '@/types'; interface GetSelectionOffsetInBlockParams { selectionTargetNode: Node | null; selectionTargetOffset: number; - blockElement: Element; + lineElement: Element; } /* *선택된 텍스트의 Line 기준 offset을 계산하는 함수 @@ -12,7 +12,7 @@ interface GetSelectionOffsetInBlockParams { export const calculateOffsetInLine = ({ selectionTargetNode, selectionTargetOffset, - blockElement, + lineElement, }: GetSelectionOffsetInBlockParams) => { const spanIndex = selectionTargetNode?.parentElement?.getAttribute('data-index'); @@ -21,10 +21,11 @@ export const calculateOffsetInLine = ({ return 0; } - const spanList = [...blockElement.querySelectorAll('span')]; + const spanList = [...lineElement.querySelectorAll('span')]; const offset = spanList.slice(0, Number(spanIndex)).reduce((acc, cur) => acc + (cur.textContent?.length || 0), 0) + selectionTargetOffset; + return offset; }; @@ -44,20 +45,25 @@ const getAnswerElementInfo = (element: Element) => { return info; }; -interface BlockData { - block: Element; +interface LineData { + line: Element; index: number; } interface GetAnswerInfoParams { - anchorLineData: BlockData; - focusLineData: BlockData; - anchorOffset: number; - focusOffset: number; + anchorLineData: LineData; + focusLineData: LineData; + anchorIndexInLine: number; + focusIndexInLine: number; } -export const getAnswerInfo = ({ anchorLineData, focusLineData, anchorOffset, focusOffset }: GetAnswerInfoParams) => { - const anchorAnswerElement = anchorLineData.block.closest(`.${EDITOR_ANSWER_CLASS_NAME}`); - const focusAnswerElement = focusLineData.block.closest(`.${EDITOR_ANSWER_CLASS_NAME}`); +export const getAnswerInfo = ({ + anchorLineData, + focusLineData, + anchorIndexInLine, + focusIndexInLine, +}: GetAnswerInfoParams) => { + const anchorAnswerElement = anchorLineData.line.closest(`.${EDITOR_ANSWER_CLASS_NAME}`); + const focusAnswerElement = focusLineData.line.closest(`.${EDITOR_ANSWER_CLASS_NAME}`); if (!anchorAnswerElement || !focusAnswerElement) return; @@ -72,12 +78,12 @@ export const getAnswerInfo = ({ anchorLineData, focusLineData, anchorOffset, foc const isForwardDragAnswer = sortedAnswerData[0].id === anchorAnswerData.id; const startAnswer = isForwardDragAnswer - ? { ...anchorAnswerData, lineIndex: Number(anchorLineData.index), offset: anchorOffset } - : { ...focusAnswerData, lineIndex: Number(focusLineData.index), offset: focusOffset }; + ? { ...anchorAnswerData, lineIndex: Number(anchorLineData.index), offset: anchorIndexInLine } + : { ...focusAnswerData, lineIndex: Number(focusLineData.index), offset: focusIndexInLine }; const endAnswer = isForwardDragAnswer - ? { ...focusAnswerData, lineIndex: Number(focusLineData.index), offset: focusOffset - 1 } - : { ...anchorAnswerData, lineIndex: Number(anchorLineData.index), offset: anchorOffset - 1 }; + ? { ...focusAnswerData, lineIndex: Number(focusLineData.index), offset: focusIndexInLine - 1 } + : { ...anchorAnswerData, lineIndex: Number(anchorLineData.index), offset: anchorIndexInLine - 1 }; return { isSameAnswer, @@ -90,30 +96,44 @@ export const getAnswerInfo = ({ anchorLineData, focusLineData, anchorOffset, foc /** * anchorNode, focusNode가 있는 element(Line) 정보를 찾는 함수 * @param selection - * @returns */ export const findSelectedLineInfo = (selection: Selection) => { const { anchorNode, focusNode, anchorOffset, focusOffset } = selection; - const anchorLine = anchorNode?.parentElement?.closest(`.${EDITOR_LINE_CLASS_NAME}`); - const focusLine = focusNode?.parentElement?.closest(`.${EDITOR_LINE_CLASS_NAME}`); + const anchorLineElement = anchorNode?.parentElement?.closest(`.${EDITOR_LINE_CLASS_NAME}`); + const focusLineElement = focusNode?.parentElement?.closest(`.${EDITOR_LINE_CLASS_NAME}`); + + if (!anchorLineElement || !focusLineElement) return; - if (!anchorLine || !focusLine) return; + const anchorLineIndex = Number(anchorLineElement.getAttribute('data-index') || '-1'); + const focusLineIndex = Number(focusLineElement.getAttribute('data-index') || '-1'); - const anchorLineIndex = Number(anchorLine.getAttribute('data-index') || '-1'); - const focusLineIndex = Number(focusLine.getAttribute('data-index') || '-1'); + // 줄 기준 Offset 비교 + const anchorIndexInLine = calculateOffsetInLine({ + selectionTargetNode: anchorNode, + selectionTargetOffset: anchorOffset, + lineElement: anchorLineElement, + }); + + const focusIndexInLine = calculateOffsetInLine({ + selectionTargetNode: focusNode, + selectionTargetOffset: focusOffset, + lineElement: focusLineElement, + }); const answerInfo = getAnswerInfo({ - anchorLineData: { block: anchorLine, index: anchorLineIndex }, - focusLineData: { block: focusLine, index: focusLineIndex }, - anchorOffset, - focusOffset, + anchorLineData: { line: anchorLineElement, index: anchorLineIndex }, + focusLineData: { line: focusLineElement, index: focusLineIndex }, + anchorIndexInLine, + focusIndexInLine, }); return { - anchorLine, + anchorLineElement, anchorLineIndex, - focusLine, + focusLineElement, focusLineIndex, + anchorIndexInLine, + focusIndexInLine, ...answerInfo, }; }; @@ -121,52 +141,53 @@ export const findSelectedLineInfo = (selection: Selection) => { export type SelectedLineInfo = Exclude, undefined>; export const calculateStartAndEndLine = ({ - anchorLine, + anchorLineElement, anchorLineIndex, - focusLine, + focusLineElement, focusLineIndex, }: SelectedLineInfo) => { const startLineIndex = Math.min(anchorLineIndex, focusLineIndex); const endLineIndex = Math.max(anchorLineIndex, focusLineIndex); - const startLine = startLineIndex === anchorLineIndex ? anchorLine : focusLine; - const endLine = startLineIndex === anchorLineIndex ? focusLine : anchorLine; + const startLineElement = startLineIndex === anchorLineIndex ? anchorLineElement : focusLineElement; + const endLineElement = startLineIndex === anchorLineIndex ? focusLineElement : anchorLineElement; return { - startLine, + startLineElement, startLineIndex, - endLine, + endLineElement, endLineIndex, }; }; interface CalculateDragDirectionParams { - selection: Selection; startLineIndex: number; endLineIndex: number; anchorLineIndex: number; + anchorIndexInLine: number; + focusIndexInLine: number; isSameAnswer: boolean; isForwardDragAnswer: boolean; } export const calculateDragDirection = ({ - selection, startLineIndex, endLineIndex, anchorLineIndex, + anchorIndexInLine, + focusIndexInLine, isSameAnswer, isForwardDragAnswer, }: CalculateDragDirectionParams) => { - const { anchorOffset, focusOffset } = selection; - const minOffset = Math.min(anchorOffset, focusOffset); + // 하이라이트 영역의 시작과 끝이 다른 답변일 경우 + if (!isSameAnswer) return isForwardDragAnswer; - if (isSameAnswer) { - const isForwardDrag = - startLineIndex === endLineIndex ? minOffset === anchorOffset : startLineIndex === anchorLineIndex; + // 하이라이트 영역의 시작과 끝이 같은 답변의 같은 줄인 경우 + const isSameLine = startLineIndex === endLineIndex; - return isForwardDrag; - } - - return isForwardDragAnswer; + // 같은 답변의 같은 줄 + if (isSameLine) return anchorIndexInLine < focusIndexInLine; + // 같은 답변의 다른 줄 + return startLineIndex === anchorLineIndex; }; /** @@ -179,14 +200,16 @@ export const findSelectionInfo = () => { const selectedElementInfo = findSelectedLineInfo(selection); if (!selectedElementInfo) return; - const { isSameAnswer } = selectedElementInfo; - const { startLine, startLineIndex, endLine, endLineIndex } = calculateStartAndEndLine(selectedElementInfo); + const { isSameAnswer, anchorIndexInLine, focusIndexInLine, anchorLineIndex } = selectedElementInfo; + const { startLineElement, startLineIndex, endLineElement, endLineIndex } = + calculateStartAndEndLine(selectedElementInfo); const isForwardDrag = calculateDragDirection({ - selection, startLineIndex, endLineIndex, - anchorLineIndex: selectedElementInfo.anchorLineIndex, + anchorLineIndex, + focusIndexInLine, + anchorIndexInLine, isSameAnswer: !!isSameAnswer, isForwardDragAnswer: !!selectedElementInfo.isForwardDragAnswer, }); @@ -195,8 +218,8 @@ export const findSelectionInfo = () => { return { selection, - startLine, - endLine, + startLineElement, + endLineElement, startLineIndex, endLineIndex, isForwardDrag, @@ -207,35 +230,34 @@ export const findSelectionInfo = () => { export type SelectionInfo = Exclude, undefined>; -export const getStartLineOffset = (infoForOffset: SelectionInfo, block: EditorLine) => { - const { isForwardDrag, startLine, selection, isOnlyOneSelectedBlock } = infoForOffset; +export const getStartLineOffset = (infoForOffset: SelectionInfo, line: EditorLine) => { + const { isForwardDrag, startLineElement, selection, isOnlyOneSelectedBlock } = infoForOffset; const { anchorNode, focusNode, anchorOffset, focusOffset } = selection; - const startIndex = calculateOffsetInLine({ selectionTargetNode: isForwardDrag ? anchorNode : focusNode, selectionTargetOffset: isForwardDrag ? anchorOffset : focusOffset, - blockElement: startLine, + lineElement: startLineElement, }); // NOTE: endIndex에 -1하는 이유 : 끝나는 포커스위치의 offset이 글자 index보다 1큼 const endIndex = isOnlyOneSelectedBlock ? calculateOffsetInLine({ selectionTargetNode: isForwardDrag ? focusNode : anchorNode, selectionTargetOffset: isForwardDrag ? focusOffset - 1 : anchorOffset - 1, - blockElement: startLine, + lineElement: startLineElement, }) - : block.text.length; + : line.text.length - 1; return { startIndex, endIndex }; }; export const getEndLineOffset = (infoForOffset: SelectionInfo) => { - const { isForwardDrag, endLine, selection } = infoForOffset; + const { isForwardDrag, endLineElement, selection } = infoForOffset; const { anchorNode, anchorOffset, focusNode, focusOffset } = selection; const endIndex = calculateOffsetInLine({ selectionTargetNode: isForwardDrag ? focusNode : anchorNode, selectionTargetOffset: isForwardDrag ? focusOffset - 1 : anchorOffset - 1, - blockElement: endLine, + lineElement: endLineElement, }); return endIndex; From c8d4992674a02d76b9c4891095b1901b7fd7062b Mon Sep 17 00:00:00 2001 From: Fe <64690761+chysis@users.noreply.github.com> Date: Tue, 22 Oct 2024 16:16:46 +0900 Subject: [PATCH 36/49] =?UTF-8?q?[FE]=20fix:=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=20=EC=99=84=EB=A3=8C=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=EC=97=90=20url=EB=A1=9C=20=EC=A7=81=EC=A0=91=20?= =?UTF-8?q?=EC=A0=91=EA=B7=BC=ED=95=98=EB=8A=94=20=ED=96=89=EC=9C=84=20?= =?UTF-8?q?=EB=B0=A9=EC=A7=80=20(#866)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 리뷰 작성 완료 페이지에 url로 직접 접근하는 행위 방지 * chore: 뒤로가기를 통해 리뷰 작성 페이지로 갈 수 없게 수정 * fix: 작성 완료 페이지에 url로 바로 접근 시 에러 페이지를 보여주도록 수정 * chore: 변경된 path 타입 적용 * refactor: 유효하지 않은 접근인 경우 홈으로 이동 버튼만 보이도록 수정 --- .../components/common/Breadcrumb/index.tsx | 17 +++++------ .../components/error/ErrorSection/index.tsx | 30 +++++++++++-------- frontend/src/hooks/useBreadcrumbPaths.ts | 2 +- .../pages/ReviewWritingCompletePage/index.tsx | 22 ++++++++++++-- .../form/hooks/answers/useSubmitAnswers.ts | 2 +- frontend/src/pages/ReviewZonePage/index.tsx | 1 - 6 files changed, 46 insertions(+), 28 deletions(-) diff --git a/frontend/src/components/common/Breadcrumb/index.tsx b/frontend/src/components/common/Breadcrumb/index.tsx index 7f543d6e9..1663c29f8 100644 --- a/frontend/src/components/common/Breadcrumb/index.tsx +++ b/frontend/src/components/common/Breadcrumb/index.tsx @@ -1,15 +1,15 @@ import React from 'react'; -import { useNavigate } from 'react-router'; +import { useLocation, useNavigate } from 'react-router'; + +import { ROUTE } from '@/constants/route'; import UndraggableWrapper from '../UndraggableWrapper'; import * as S from './styles'; -type PathType = string | number; - export interface Path { pageName: string; - path: PathType; + path: string; } interface BreadcrumbProps { @@ -18,13 +18,10 @@ interface BreadcrumbProps { const Breadcrumb = ({ pathList }: BreadcrumbProps) => { const navigate = useNavigate(); + const location = useLocation(); - const handleNavigation = (path: PathType) => { - if (typeof path === 'number') { - navigate(path); - } else { - navigate(path); - } + const handleNavigation = (path: string) => { + navigate(path, { replace: location.pathname.includes(`/${ROUTE.reviewWritingComplete}`) }); }; return ( diff --git a/frontend/src/components/error/ErrorSection/index.tsx b/frontend/src/components/error/ErrorSection/index.tsx index 9e65d00dd..6e50d023b 100644 --- a/frontend/src/components/error/ErrorSection/index.tsx +++ b/frontend/src/components/error/ErrorSection/index.tsx @@ -13,6 +13,7 @@ export interface ErrorSectionProps { errorMessage: string; handleReload: () => void; handleGoOtherPage: () => void; + errorType?: 'notFound' | 'invalidAccess'; } export interface ErrorSectionButton { @@ -25,27 +26,30 @@ export interface ErrorSectionButton { onClick: () => void; } -const ErrorSection = ({ errorMessage, handleReload, handleGoOtherPage }: ErrorSectionProps) => { +const ErrorSection = ({ errorMessage, handleReload, handleGoOtherPage, errorType = 'notFound' }: ErrorSectionProps) => { const isGoHomeButtonFirst = errorMessage === ROUTE_ERROR_MESSAGE; // errorMessage에 따른 커스텀 - const buttonList: ErrorSectionButton[] = [ - { + const buttonList: ErrorSectionButton[] = []; + + if (errorType === 'notFound') { + buttonList.push({ buttonType: isGoHomeButtonFirst ? 'secondary' : 'primary', key: 'refreshButton', text: '새로고침하기', imageSrc: isGoHomeButtonFirst ? PrimaryReloadIcon : WhiteReloadIcon, imageDescription: '새로고침 이미지', onClick: handleReload, - }, - { - buttonType: isGoHomeButtonFirst ? 'primary' : 'secondary', - key: 'homeButton', - text: '홈으로 이동하기', - imageSrc: isGoHomeButtonFirst ? WhiteHomeIcon : PrimaryHomeIcon, - imageDescription: '홈 이미지', - onClick: handleGoOtherPage, - }, - ]; + }); + } + + buttonList.push({ + buttonType: errorType === 'invalidAccess' ? 'primary' : isGoHomeButtonFirst ? 'primary' : 'secondary', + key: 'homeButton', + text: '홈으로 이동하기', + imageSrc: errorType === 'invalidAccess' ? WhiteHomeIcon : isGoHomeButtonFirst ? WhiteHomeIcon : PrimaryHomeIcon, + imageDescription: '홈 이미지', + onClick: handleGoOtherPage, + }); const errorSectionButtonList = isGoHomeButtonFirst ? buttonList.reverse() : buttonList; diff --git a/frontend/src/hooks/useBreadcrumbPaths.ts b/frontend/src/hooks/useBreadcrumbPaths.ts index 071cbfbf0..d41fa0f40 100644 --- a/frontend/src/hooks/useBreadcrumbPaths.ts +++ b/frontend/src/hooks/useBreadcrumbPaths.ts @@ -34,7 +34,7 @@ const useBreadcrumbPaths = () => { if (pathname.includes(`/${ROUTE.reviewWritingComplete}`)) { breadcrumbPathList.push( { pageName: '리뷰 작성', path: `${ROUTE.reviewWriting}/${reviewRequestCode}` }, - { pageName: '리뷰 작성 완료 페이지', path: pathname }, + { pageName: '리뷰 작성 완료', path: pathname }, ); } diff --git a/frontend/src/pages/ReviewWritingCompletePage/index.tsx b/frontend/src/pages/ReviewWritingCompletePage/index.tsx index 69fc8ced0..b6c6ef0a4 100644 --- a/frontend/src/pages/ReviewWritingCompletePage/index.tsx +++ b/frontend/src/pages/ReviewWritingCompletePage/index.tsx @@ -1,18 +1,36 @@ -import { useNavigate } from 'react-router'; +import { useEffect, useState } from 'react'; +import { useLocation, useNavigate } from 'react-router'; import PrimaryHomeIcon from '@/assets/primaryHome.svg'; import SmileIcon from '@/assets/smile.svg'; -import { Button } from '@/components'; +import { Button, ErrorSection } from '@/components'; import * as S from './styles'; const ReviewWritingCompletePage = () => { + const [isValid, setIsValid] = useState(true); const navigate = useNavigate(); + const location = useLocation(); + + useEffect(() => { + if (!location.state || !location.state.isValidAccess) setIsValid(false); + }, [location]); const handleClickHomeButton = () => { navigate('/', { replace: true }); }; + if (!isValid) { + return ( + navigate(0)} + handleGoOtherPage={() => navigate('/', { replace: true })} + errorType="invalidAccess" + /> + ); + } + return ( diff --git a/frontend/src/pages/ReviewWritingPage/form/hooks/answers/useSubmitAnswers.ts b/frontend/src/pages/ReviewWritingPage/form/hooks/answers/useSubmitAnswers.ts index 3c1e210d0..9af130402 100644 --- a/frontend/src/pages/ReviewWritingPage/form/hooks/answers/useSubmitAnswers.ts +++ b/frontend/src/pages/ReviewWritingPage/form/hooks/answers/useSubmitAnswers.ts @@ -21,7 +21,7 @@ const useSubmitAnswers = ({ closeSubmitConfirmModal }: UseSubmitAnswersProps) => const navigate = useNavigate(); const executeAfterMutateSuccess = () => { - navigate(`/${ROUTE.reviewWritingComplete}/${reviewRequestCode}`); + navigate(`/${ROUTE.reviewWritingComplete}/${reviewRequestCode}`, { state: { isValidAccess: true } }); closeSubmitConfirmModal(); }; diff --git a/frontend/src/pages/ReviewZonePage/index.tsx b/frontend/src/pages/ReviewZonePage/index.tsx index 1aabe8411..790431550 100644 --- a/frontend/src/pages/ReviewZonePage/index.tsx +++ b/frontend/src/pages/ReviewZonePage/index.tsx @@ -49,7 +49,6 @@ const ReviewZonePage = () => { - {/* NOTE: 추후 API 연동되면 서버에서 받아온 이름들을 출력하도록 수정해야 함 */} {`${reviewGroupData.projectName}${calculateParticle({ target: reviewGroupData.projectName, particles: { withFinalConsonant: '을', withoutFinalConsonant: '를' } })} 함께한`} {`${reviewGroupData.revieweeName}의 리뷰 공간이에요`} From 87b796793eac877e5feb8584d3aa55b2cd0d0851 Mon Sep 17 00:00:00 2001 From: ImxYJL <111052302+ImxYJL@users.noreply.github.com> Date: Tue, 22 Oct 2024 16:47:12 +0900 Subject: [PATCH 37/49] =?UTF-8?q?[FE]=20feat:=20Checkbox,=20CheckboxItem?= =?UTF-8?q?=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=A0=91=EA=B7=BC?= =?UTF-8?q?=EC=84=B1=20=EA=B0=9C=EC=84=A0=20(#889)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Checkbox 선택 여부를 스크린 리더 사용자에게 안내하는 기능 추가 * feat: 탭 접근성 강화 1. Checkbox와 CheckboxItem의 탭 포커스 분리 2. CheckboxItem에 포커싱됐을 때 엔터를 누르면 해당 체크박스를 선택할 수 있는 기능 추가 3. CheckboxItem의 focus시 outline 색상을 primary로 변경 * feat: CheckboxItem을 전체 선택했을 때도 선택 여부를 알려주도록 변경 * refactor: handleKeyDown 이벤트 핸들러에서 사용하는 ChangeEvent 타입 캐스팅을 partial을 사용해 명확하게 변경 --- .../src/components/common/Checkbox/index.tsx | 23 ++++++++++-- .../components/common/CheckboxItem/index.tsx | 35 +++++++++++++++++-- .../components/common/CheckboxItem/styles.ts | 4 +++ 3 files changed, 57 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/common/Checkbox/index.tsx b/frontend/src/components/common/Checkbox/index.tsx index cd4c08c13..f99156117 100644 --- a/frontend/src/components/common/Checkbox/index.tsx +++ b/frontend/src/components/common/Checkbox/index.tsx @@ -13,12 +13,22 @@ export interface CheckboxStyleProps { export interface CheckboxProps extends CheckboxStyleProps { id: string; isChecked: boolean; + isTabAccessible?: boolean; handleChange?: (event: ChangeEvent, label?: string) => void; name?: string; isDisabled?: boolean; } -const Checkbox = ({ id, isChecked, handleChange, isDisabled, $style, $isReadonly = false, ...rest }: CheckboxProps) => { +const Checkbox = ({ + id, + isChecked, + handleChange, + isDisabled, + isTabAccessible = true, + $style, + $isReadonly = false, + ...rest +}: CheckboxProps) => { return ( @@ -29,9 +39,18 @@ const Checkbox = ({ id, isChecked, handleChange, isDisabled, $style, $isReadonly disabled={isDisabled} type="checkbox" onChange={handleChange} + tabIndex={-1} {...rest} /> - 체크박스 + + {$isReadonly && {isChecked ? '선택됨' : '선택 안 됨'}} ); diff --git a/frontend/src/components/common/CheckboxItem/index.tsx b/frontend/src/components/common/CheckboxItem/index.tsx index db8263c98..f42ef24af 100644 --- a/frontend/src/components/common/CheckboxItem/index.tsx +++ b/frontend/src/components/common/CheckboxItem/index.tsx @@ -1,3 +1,5 @@ +import { ChangeEvent } from 'react'; + import Checkbox, { CheckboxProps } from '../Checkbox'; import UndraggableWrapper from '../UndraggableWrapper'; @@ -7,12 +9,39 @@ interface CheckboxItemProps extends CheckboxProps { label: string; } -const CheckboxItem = ({ label, ...rest }: CheckboxItemProps) => { +const CheckboxItem = ({ + id, + label, + isChecked, + handleChange, + $isReadonly, + isTabAccessible = false, + ...rest +}: CheckboxItemProps) => { + const isCheckedLabel = `${label}, ${isChecked ? '선택됨' : '선택 안 됨'}`; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter' && handleChange) { + handleChange({ + currentTarget: { + id: id, + checked: !isChecked, + } as Partial, + } as ChangeEvent); + } + }; + return ( - + - + {label} diff --git a/frontend/src/components/common/CheckboxItem/styles.ts b/frontend/src/components/common/CheckboxItem/styles.ts index 5b24312e3..1c7a09709 100644 --- a/frontend/src/components/common/CheckboxItem/styles.ts +++ b/frontend/src/components/common/CheckboxItem/styles.ts @@ -3,6 +3,10 @@ import styled from '@emotion/styled'; export const CheckboxItem = styled.div` display: flex; margin-bottom: 1rem; + + :focus-visible { + outline: 0.3rem solid ${({ theme }) => theme.colors.primary}; + } `; export const CheckboxLabel = styled.label` From 5c1e17717de49ea402b59562453de1b3aff05234 Mon Sep 17 00:00:00 2001 From: Fe <64690761+chysis@users.noreply.github.com> Date: Tue, 22 Oct 2024 16:52:11 +0900 Subject: [PATCH 38/49] =?UTF-8?q?[FE]=20fix:=20api=20=EC=A4=91=EB=B3=B5=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20=EB=B0=A9=EC=A7=80=20(#897)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 리뷰 제출 api 중복 요청 방지 * feat: post 요청에 대한 api 중복 요청 방지 --- .../HighlightEditor/hooks/useMutateHighlight/index.ts | 4 +++- .../HomePage/hooks/usePostDataForReviewRequestCode/index.ts | 5 ++++- .../ReviewWritingPage/form/hooks/useMutateReview/index.ts | 3 +++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/highlight/components/HighlightEditor/hooks/useMutateHighlight/index.ts b/frontend/src/components/highlight/components/HighlightEditor/hooks/useMutateHighlight/index.ts index 79951c40e..696e01446 100644 --- a/frontend/src/components/highlight/components/HighlightEditor/hooks/useMutateHighlight/index.ts +++ b/frontend/src/components/highlight/components/HighlightEditor/hooks/useMutateHighlight/index.ts @@ -19,7 +19,9 @@ const useMutateHighlight = ({ }: UseMutateHighlightProps) => { const mutation = useMutation({ mutationFn: (newEditorAnswerMap: EditorAnswerMap) => postHighlight(newEditorAnswerMap, questionId), - + onMutate: () => { + if (mutation.isPending) return; + }, onSuccess: (_, variables: EditorAnswerMap) => { updateEditorAnswerMap(variables); resetHighlightMenu(); diff --git a/frontend/src/pages/HomePage/hooks/usePostDataForReviewRequestCode/index.ts b/frontend/src/pages/HomePage/hooks/usePostDataForReviewRequestCode/index.ts index 65322d7eb..3dd6298bc 100644 --- a/frontend/src/pages/HomePage/hooks/usePostDataForReviewRequestCode/index.ts +++ b/frontend/src/pages/HomePage/hooks/usePostDataForReviewRequestCode/index.ts @@ -6,9 +6,12 @@ import { GROUP_QUERY_KEY } from '@/constants'; const usePostDataForReviewRequestCode = () => { const queryClient = useQueryClient(); - const { mutate, isSuccess, data } = useMutation({ + const { mutate, isSuccess, isPending, data } = useMutation({ mutationFn: (dataForReviewRequestCode: DataForReviewRequestCode) => postDataForReviewRequestCodeApi(dataForReviewRequestCode), + onMutate: () => { + if (isPending) return; + }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: [GROUP_QUERY_KEY.dataForReviewRequestCode] }); }, diff --git a/frontend/src/pages/ReviewWritingPage/form/hooks/useMutateReview/index.ts b/frontend/src/pages/ReviewWritingPage/form/hooks/useMutateReview/index.ts index b49f8d8fc..30c6aff94 100644 --- a/frontend/src/pages/ReviewWritingPage/form/hooks/useMutateReview/index.ts +++ b/frontend/src/pages/ReviewWritingPage/form/hooks/useMutateReview/index.ts @@ -12,6 +12,9 @@ const useMutateReview = ({ executeAfterMutateSuccess }: UseMutateReviewProps) => const reviewMutation = useMutation({ mutationFn: (formResult: ReviewWritingFormResult) => postReviewApi(formResult), + onMutate: () => { + if (reviewMutation.isPending) return; + }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: [REVIEW_QUERY_KEY.postReview] }); executeAfterMutateSuccess(); From 0f5de937e9d9290eaadde6a2f0e417e1d226e37d Mon Sep 17 00:00:00 2001 From: ImxYJL <111052302+ImxYJL@users.noreply.github.com> Date: Tue, 22 Oct 2024 16:58:20 +0900 Subject: [PATCH 39/49] =?UTF-8?q?[FE]=20=EB=A6=AC=EB=B7=B0=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1=20=ED=8E=98=EC=9D=B4=EC=A7=80=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=8F=99=EC=A0=81=EC=9C=BC=EB=A1=9C=20=EC=97=90=EB=9F=AC?= =?UTF-8?q?=EA=B0=80=20=EC=83=9D=EA=B8=B0=EB=8A=94=20=EA=B2=BD=EC=9A=B0,?= =?UTF-8?q?=20=ED=95=B4=EB=8B=B9=20=EC=97=90=EB=9F=AC=EB=A5=BC=20=EC=9D=BD?= =?UTF-8?q?=EC=96=B4=EC=A3=BC=EB=8A=94=20=EA=B8=B0=EB=8A=A5=20(#891)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Checkbox 선택 여부를 스크린 리더 사용자에게 안내하는 기능 추가 * feat: 탭 접근성 강화 1. Checkbox와 CheckboxItem의 탭 포커스 분리 2. CheckboxItem에 포커싱됐을 때 엔터를 누르면 해당 체크박스를 선택할 수 있는 기능 추가 3. CheckboxItem의 focus시 outline 색상을 primary로 변경 * feat: 에러 메세지가 등장하는 경우 해당 에러 메세지 컴포넌트에 포커스를 주는 훅 작성 * feat: useFocusMessage 훅 적용 * feat: CheckboxItem을 전체 선택했을 때도 선택 여부를 알려주도록 변경 * refactor: handleKeyDown 이벤트 핸들러에서 사용하는 ChangeEvent 타입 캐스팅을 partial을 사용해 명확하게 변경 * refactor: 업데이트된 sr-only 스타일 반영 --- .../components/MultipleChoiceAnswer/index.tsx | 8 +++++-- .../form/components/TextAnswer/index.tsx | 8 +++++-- .../ReviewWritingPage/form/hooks/index.ts | 1 + .../form/hooks/useFocusMessage.ts | 21 +++++++++++++++++++ frontend/src/styles/globalStyles.ts | 16 ++++++++++++++ 5 files changed, 50 insertions(+), 4 deletions(-) create mode 100644 frontend/src/pages/ReviewWritingPage/form/hooks/useFocusMessage.ts diff --git a/frontend/src/pages/ReviewWritingPage/form/components/MultipleChoiceAnswer/index.tsx b/frontend/src/pages/ReviewWritingPage/form/components/MultipleChoiceAnswer/index.tsx index 3087991bf..f2ae888d3 100644 --- a/frontend/src/pages/ReviewWritingPage/form/components/MultipleChoiceAnswer/index.tsx +++ b/frontend/src/pages/ReviewWritingPage/form/components/MultipleChoiceAnswer/index.tsx @@ -1,6 +1,6 @@ import { CheckboxItem } from '@/components'; import { useModals } from '@/hooks'; -import { useMultipleChoice } from '@/pages/ReviewWritingPage/form/hooks'; +import { useMultipleChoice, useFocusMessage } from '@/pages/ReviewWritingPage/form/hooks'; import { StrengthUnCheckModal } from '@/pages/ReviewWritingPage/modals/components'; import { ReviewWritingCardQuestion } from '@/types'; @@ -28,6 +28,8 @@ const MultipleChoiceAnswer = ({ question }: MultipleChoiceAnswerProps) => { }, ); + const { messageRef } = useFocusMessage({ isMessageShown: isOpenLimitGuide }); + const handleModalCancelButtonClick = () => { closeModal(MODAL_KEY.confirm); }; @@ -51,7 +53,9 @@ const MultipleChoiceAnswer = ({ question }: MultipleChoiceAnswerProps) => { ))} {isOpenLimitGuide && ( -

😅 최대 {question.optionGroup?.maxCount}개까지 선택가능해요

+

+ 😅 최대 {question.optionGroup?.maxCount}개까지 선택가능해요 +

)}
{isOpen(MODAL_KEY.confirm) && ( diff --git a/frontend/src/pages/ReviewWritingPage/form/components/TextAnswer/index.tsx b/frontend/src/pages/ReviewWritingPage/form/components/TextAnswer/index.tsx index 9f76b4f13..601d8d110 100644 --- a/frontend/src/pages/ReviewWritingPage/form/components/TextAnswer/index.tsx +++ b/frontend/src/pages/ReviewWritingPage/form/components/TextAnswer/index.tsx @@ -1,4 +1,4 @@ -import { useTextAnswer } from '@/pages/ReviewWritingPage/form/hooks'; +import { useFocusMessage, useTextAnswer } from '@/pages/ReviewWritingPage/form/hooks'; import { ReviewWritingCardQuestion } from '@/types'; import * as S from './styles'; @@ -12,6 +12,8 @@ const TextAnswer = ({ question }: TextAnswerProps) => { question, }); + const { messageRef } = useFocusMessage({ isMessageShown: errorMessage !== '' }); + const textLength = `${text.length} / ${maxLength}`; return ( @@ -24,7 +26,9 @@ const TextAnswer = ({ question }: TextAnswerProps) => { onBlur={handleTextAnswerBlur} /> - {errorMessage} + + {errorMessage} + {textLength} diff --git a/frontend/src/pages/ReviewWritingPage/form/hooks/index.ts b/frontend/src/pages/ReviewWritingPage/form/hooks/index.ts index 84fdf367a..252368e3a 100644 --- a/frontend/src/pages/ReviewWritingPage/form/hooks/index.ts +++ b/frontend/src/pages/ReviewWritingPage/form/hooks/index.ts @@ -4,3 +4,4 @@ export { default as useMutateReview } from './useMutateReview'; export { default as useCurrentCardIndex } from './useCurrentCardIndex'; export { default as useNavigateBlocker } from './useNavigateBlocker'; export { default as useResetFormRecoil } from './useResetFormRecoil'; +export { default as useFocusMessage } from './useFocusMessage'; diff --git a/frontend/src/pages/ReviewWritingPage/form/hooks/useFocusMessage.ts b/frontend/src/pages/ReviewWritingPage/form/hooks/useFocusMessage.ts new file mode 100644 index 000000000..3ab4cf24c --- /dev/null +++ b/frontend/src/pages/ReviewWritingPage/form/hooks/useFocusMessage.ts @@ -0,0 +1,21 @@ +import { useRef, useEffect } from 'react'; + +interface useMessageFocusProps { + isMessageShown: boolean; +} + +const useFocusMessage = ({ isMessageShown }: useMessageFocusProps) => { + const messageRef = useRef(null); + + useEffect(() => { + if (isMessageShown && messageRef.current) { + messageRef.current.focus(); + } + }, [isMessageShown]); + + return { + messageRef, + }; +}; + +export default useFocusMessage; diff --git a/frontend/src/styles/globalStyles.ts b/frontend/src/styles/globalStyles.ts index c316123fc..b54628192 100644 --- a/frontend/src/styles/globalStyles.ts +++ b/frontend/src/styles/globalStyles.ts @@ -49,6 +49,22 @@ const globalStyles = (theme: Theme) => css` background: transparent; } } + + .sr-only { + position: absolute; + + overflow: hidden; + + width: 0.1rem; + height: 0.1rem; + margin: -0.1rem; + padding: 0; + + white-space: nowrap; + + clip: rect(0, 0, 0, 0); + border: 0; + } `; export default globalStyles; From 3098e2a7b0356472684c58fdf9232e8ebccb850d Mon Sep 17 00:00:00 2001 From: badahertz52 Date: Tue, 22 Oct 2024 17:03:23 +0900 Subject: [PATCH 40/49] =?UTF-8?q?[FE]=20feat:=20=ED=98=84=EC=9E=AC=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EC=9E=91=EC=84=B1=20=EC=B9=B4=EB=93=9C?= =?UTF-8?q?=EC=97=90=EB=A7=8C=20tab=20=EC=9D=B4=EB=8F=99=ED=95=98=EB=8A=94?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20?= =?UTF-8?q?=EB=A1=9C=EA=B3=A0,BreadCrumb=20=ED=8F=AC=EC=BB=A4=EC=8A=A4=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99=20=EA=B5=AC=ED=98=84=20(#881)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 로코,BreadCrumb 탭을 이동할 수 있도록 시맨틱 태그 변경 * feat: useTabNavigationOnValidity 훅 생성 및 적용 * refactor: Logo 시맨틱 구조 변경(Link 사용) 및 logo 이미지 파일 삭제 * fix: 잘못된 타입 강제 수정 * feat: BreadCrumb에 nav 태그 추가 --- frontend/src/assets/logo.svg | 25 ----------- .../components/common/Breadcrumb/index.tsx | 30 +++++-------- .../layouts/Topbar/components/Logo/index.tsx | 15 ++----- .../layouts/Topbar/components/Logo/styles.ts | 16 ------- .../slider/components/CardSlider/index.tsx | 10 ++++- .../hooks/ally/useTabNavigationOnValidity.ts | 45 +++++++++++++++++++ .../ReviewWritingPage/slider/hooks/index.ts | 1 + frontend/src/styles/reset.ts | 9 ++++ 8 files changed, 78 insertions(+), 73 deletions(-) delete mode 100644 frontend/src/assets/logo.svg create mode 100644 frontend/src/pages/ReviewWritingPage/slider/hooks/ally/useTabNavigationOnValidity.ts diff --git a/frontend/src/assets/logo.svg b/frontend/src/assets/logo.svg deleted file mode 100644 index 85a758106..000000000 --- a/frontend/src/assets/logo.svg +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/src/components/common/Breadcrumb/index.tsx b/frontend/src/components/common/Breadcrumb/index.tsx index 1663c29f8..ce93e063d 100644 --- a/frontend/src/components/common/Breadcrumb/index.tsx +++ b/frontend/src/components/common/Breadcrumb/index.tsx @@ -1,7 +1,4 @@ -import React from 'react'; -import { useLocation, useNavigate } from 'react-router'; - -import { ROUTE } from '@/constants/route'; +import { Link } from 'react-router-dom'; import UndraggableWrapper from '../UndraggableWrapper'; @@ -17,21 +14,18 @@ interface BreadcrumbProps { } const Breadcrumb = ({ pathList }: BreadcrumbProps) => { - const navigate = useNavigate(); - const location = useLocation(); - - const handleNavigation = (path: string) => { - navigate(path, { replace: location.pathname.includes(`/${ROUTE.reviewWritingComplete}`) }); - }; - return ( - - {pathList.map(({ pageName, path }, index) => ( - handleNavigation(path)}> - {pageName} - - ))} - + ); }; diff --git a/frontend/src/components/layouts/Topbar/components/Logo/index.tsx b/frontend/src/components/layouts/Topbar/components/Logo/index.tsx index 9b031d46b..8d01c1641 100644 --- a/frontend/src/components/layouts/Topbar/components/Logo/index.tsx +++ b/frontend/src/components/layouts/Topbar/components/Logo/index.tsx @@ -1,23 +1,14 @@ -// import LogoIcon from '../../../../../assets/logo.svg'; - -import { useNavigate } from 'react-router'; +import { Link } from 'react-router-dom'; import * as S from './styles'; const Logo = () => { - const navigate = useNavigate(); - - const handleLogoClick = () => { - navigate('/'); - }; - return ( - {/* 로고 아이콘 */} - + REVIEW ME - + ); }; diff --git a/frontend/src/components/layouts/Topbar/components/Logo/styles.ts b/frontend/src/components/layouts/Topbar/components/Logo/styles.ts index b4d846f3d..682e0908d 100644 --- a/frontend/src/components/layouts/Topbar/components/Logo/styles.ts +++ b/frontend/src/components/layouts/Topbar/components/Logo/styles.ts @@ -3,22 +3,6 @@ import styled from '@emotion/styled'; import media from '@/utils/media'; export const Logo = styled.div` - display: flex; - gap: 0.5rem; - align-items: center; - - img { - width: 4rem; - height: 4rem; - } -`; - -export const LogoText = styled.div` - cursor: pointer; - - display: flex; - align-items: center; - line-height: 8rem; text-align: center; diff --git a/frontend/src/pages/ReviewWritingPage/slider/components/CardSlider/index.tsx b/frontend/src/pages/ReviewWritingPage/slider/components/CardSlider/index.tsx index 8957051ec..a95a91c61 100644 --- a/frontend/src/pages/ReviewWritingPage/slider/components/CardSlider/index.tsx +++ b/frontend/src/pages/ReviewWritingPage/slider/components/CardSlider/index.tsx @@ -4,7 +4,11 @@ import { Carousel } from '@/components'; import { CARD_FORM_MODAL_KEY } from '@/pages/ReviewWritingPage/constants'; import { ReviewWritingCard } from '@/pages/ReviewWritingPage/form/components'; import { CardSliderController } from '@/pages/ReviewWritingPage/slider/components'; -import { useMovingStepAvailability, useSlideHeight } from '@/pages/ReviewWritingPage/slider/hooks'; +import { + useMovingStepAvailability, + useSlideHeight, + useTabNavigationOnValidity, +} from '@/pages/ReviewWritingPage/slider/hooks'; import { Direction } from '@/pages/ReviewWritingPage/types'; import { cardSectionListSelector } from '@/recoil'; @@ -18,8 +22,8 @@ interface CardSliderProps { const CardSlider = ({ currentCardIndex, handleCurrentCardIndex, handleOpenModal }: CardSliderProps) => { const cardSectionList = useRecoilValue(cardSectionListSelector); - const { wrapperRef, slideHeight, makeId } = useSlideHeight({ currentCardIndex }); + const { wrapperRef, slideHeight, makeId } = useSlideHeight({ currentCardIndex }); const { isAblePrevStep, isAbleNextStep, isLastCard } = useMovingStepAvailability({ currentCardIndex }); const handleNextClick = () => { @@ -37,6 +41,8 @@ const CardSlider = ({ currentCardIndex, handleCurrentCardIndex, handleOpenModal handleOpenModal('submitConfirm'); }; + useTabNavigationOnValidity({ cardId: makeId(currentCardIndex) }); + return ( {cardSectionList?.map((section, index) => ( diff --git a/frontend/src/pages/ReviewWritingPage/slider/hooks/ally/useTabNavigationOnValidity.ts b/frontend/src/pages/ReviewWritingPage/slider/hooks/ally/useTabNavigationOnValidity.ts new file mode 100644 index 000000000..ae5ef77eb --- /dev/null +++ b/frontend/src/pages/ReviewWritingPage/slider/hooks/ally/useTabNavigationOnValidity.ts @@ -0,0 +1,45 @@ +import { useEffect } from 'react'; +import { useRecoilValue } from 'recoil'; + +import { cardSectionListSelector } from '@/recoil'; +import { isExistentElement } from '@/utils'; + +interface UseTabNavigationOnValidityProps { + cardId: string; +} +/** + * 현재 리뷰 작성 카드안에서 tab할 수 있는 마지막 요소를 감별해, 해당 요소에 포커스가 있을 때 tab키가 눌리면 footer의 첫번째 a로 포커스를 이동시키는 훅 + */ +const useTabNavigationOnValidity = ({ cardId }: UseTabNavigationOnValidityProps) => { + const cardSectionList = useRecoilValue(cardSectionListSelector); + + const findCurrentCardElement = () => { + const currentCardElement = document.getElementById(cardId); + if (!isExistentElement(currentCardElement, '현재 리뷰 작성 카드')) return; + + return currentCardElement; + }; + + const handleTabKeydown = (event: KeyboardEvent) => { + if (event.code !== 'Tab') return; + const currentCardElement = findCurrentCardElement(); + const lastTabCandidateList = currentCardElement?.querySelectorAll('input, textarea, button:not([disabled])'); + if (!lastTabCandidateList || lastTabCandidateList.length === 0) return; + + const lastTabElementInCard = lastTabCandidateList[lastTabCandidateList.length - 1]; + if (document.activeElement !== lastTabElementInCard) return; + + //카드 속에서 마지막 탭 요소에 focus되어있고, tab키 누를 경우 + event.preventDefault(); + (document.querySelector('footer a') as HTMLElement | null)?.focus(); + }; + + useEffect(() => { + document.addEventListener('keydown', handleTabKeydown); + return () => { + document.removeEventListener('keydown', handleTabKeydown); + }; + }, [cardId, cardSectionList]); +}; + +export default useTabNavigationOnValidity; diff --git a/frontend/src/pages/ReviewWritingPage/slider/hooks/index.ts b/frontend/src/pages/ReviewWritingPage/slider/hooks/index.ts index 25cc3af28..ea4a4d597 100644 --- a/frontend/src/pages/ReviewWritingPage/slider/hooks/index.ts +++ b/frontend/src/pages/ReviewWritingPage/slider/hooks/index.ts @@ -1,2 +1,3 @@ export { default as useMovingStepAvailability } from './useMovingStepAvailability'; export { default as useSlideHeight } from './useSlideHeight'; +export { default as useTabNavigationOnValidity } from './ally/useTabNavigationOnValidity'; diff --git a/frontend/src/styles/reset.ts b/frontend/src/styles/reset.ts index 8084d9042..31a2396d2 100644 --- a/frontend/src/styles/reset.ts +++ b/frontend/src/styles/reset.ts @@ -112,6 +112,15 @@ const reset = () => css` background-color: transparent; border: none; } + + a, + a:active, + a:focus, + a:visited, + a:hover { + color: inherit; + text-decoration: none; + } `; export default reset; From f90f553334fb5b18885675b96427adbcbe6af06c Mon Sep 17 00:00:00 2001 From: badahertz52 Date: Tue, 22 Oct 2024 17:40:12 +0900 Subject: [PATCH 41/49] =?UTF-8?q?[FE]=20feat:=20Amplitude=EB=A5=BC=20?= =?UTF-8?q?=ED=86=B5=ED=95=9C=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=88=98=EC=A7=91=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20=20(#898)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: index.tsx 에서 router, sentry, startMocker 분리 * ci: @amplitude/analytics-browser 설치 * feat: amplitude 셋팅 * chore: GA 코드 삭제 * feat: 모아보기 페이지에 amplitude 적용 * feat: amplitude로 방문 페이지 데이터 수집 기능 구현 * feat: 리뷰 제출 모달의 확인 버튼 이벤트에 amplitude 추적 추가 * feat: 리뷰 URL 생성 버튼에 amplitude 추적 추가 * refactor: getPageName 리팩토링 * feat: 리뷰 URL 버튼 클릭 시 amplitude 추적 코드 위치 변경 * feat: Amplitude의 OS를 사용하기로 하고, 브라우저 정보를 가져오는 함수 삭제 * chore: MobileProgressBar에서 사용하지 않는 NavigateNextIcon import문 삭제 * fix: env 오타 수정 --- frontend/package.json | 1 + frontend/public/index.html | 12 --- frontend/src/App.tsx | 3 + .../HighlightEditor/hooks/useEditableState.ts | 6 +- .../HighlightEditor/hooks/useHighlight.ts | 9 +- .../useReviewDisplayLayoutOptions/index.ts | 19 +++- frontend/src/constants/amplitudeEventName.ts | 35 ++++++ frontend/src/constants/index.ts | 1 + frontend/src/hooks/index.ts | 1 + .../hooks/useTrackVisitedPageInAmplitude.ts | 36 +++++++ frontend/src/index.tsx | 100 ++---------------- .../components/URLGeneratorForm/index.tsx | 6 +- .../components/SubmitCheckModal/index.tsx | 9 +- .../components/MobileProgressBar/index.tsx | 1 - frontend/src/router.tsx | 59 +++++++++++ frontend/src/types/amplitude.ts | 3 + frontend/src/types/index.ts | 1 + frontend/src/utils/analytics/amplitude.ts | 33 ++++++ frontend/src/utils/analytics/index.ts | 2 + frontend/src/utils/analytics/sentry.ts | 17 +++ frontend/src/utils/index.ts | 3 + frontend/src/utils/mockWorkerStarter.ts | 8 ++ frontend/src/utils/queryRetrier.ts | 14 +++ frontend/yarn.lock | 82 ++++++++++++++ 24 files changed, 350 insertions(+), 111 deletions(-) create mode 100644 frontend/src/constants/amplitudeEventName.ts create mode 100644 frontend/src/hooks/useTrackVisitedPageInAmplitude.ts create mode 100644 frontend/src/router.tsx create mode 100644 frontend/src/types/amplitude.ts create mode 100644 frontend/src/utils/analytics/amplitude.ts create mode 100644 frontend/src/utils/analytics/index.ts create mode 100644 frontend/src/utils/analytics/sentry.ts create mode 100644 frontend/src/utils/mockWorkerStarter.ts create mode 100644 frontend/src/utils/queryRetrier.ts diff --git a/frontend/package.json b/frontend/package.json index 7d044f2d2..1075ce7a8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ "test": "jest" }, "dependencies": { + "@amplitude/analytics-browser": "^2.11.8", "@emotion/react": "^11.11.4", "@emotion/styled": "^11.11.5", "@sentry/react": "^8.23.0", diff --git a/frontend/public/index.html b/frontend/public/index.html index 2f05f7266..789f3ec08 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -2,18 +2,6 @@ - - - diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 58a3a8e68..b0f5c33b0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,8 +1,11 @@ import { Outlet } from 'react-router'; import { PageLayout } from './components'; +import { useTrackVisitedPageInAmplitude } from './hooks'; const App = () => { + useTrackVisitedPageInAmplitude(); + return ( diff --git a/frontend/src/components/highlight/components/HighlightEditor/hooks/useEditableState.ts b/frontend/src/components/highlight/components/HighlightEditor/hooks/useEditableState.ts index deead33fa..d865220be 100644 --- a/frontend/src/components/highlight/components/HighlightEditor/hooks/useEditableState.ts +++ b/frontend/src/components/highlight/components/HighlightEditor/hooks/useEditableState.ts @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; -import { SESSION_STORAGE_KEY } from '@/constants'; +import { HIGHLIGHT_EVENT_NAME, SESSION_STORAGE_KEY } from '@/constants'; +import { trackEventInAmplitude } from '@/utils'; const useEditableState = () => { const [isEditable, setIsEditable] = useState(false); @@ -17,7 +18,10 @@ const useEditableState = () => { const handleEditToggleButton = () => { setIsEditable((prev) => { + if (!prev) trackEventInAmplitude(HIGHLIGHT_EVENT_NAME.openHighlightEditor); + prev ? removeHighlightEditorStateFromStorage() : saveHighlightEditorStateInStorage(); + return !prev; }); }; diff --git a/frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlight.ts b/frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlight.ts index 23acfebc3..1f286b992 100644 --- a/frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlight.ts +++ b/frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlight.ts @@ -1,6 +1,6 @@ import { useState } from 'react'; -import { EDITOR_ANSWER_CLASS_NAME, HIGHLIGHT_SPAN_CLASS_NAME } from '@/constants'; +import { EDITOR_ANSWER_CLASS_NAME, HIGHLIGHT_EVENT_NAME, HIGHLIGHT_SPAN_CLASS_NAME } from '@/constants'; import { EditorAnswerMap, EditorLine, HighlightResponseData, ReviewAnswerResponseData } from '@/types'; import { getEndLineOffset, @@ -10,6 +10,7 @@ import { getUpdatedBlockByHighlight, removeSelection, SelectionInfo, + trackEventInAmplitude, } from '@/utils'; import { UseLongPressHighlightPositionReturn } from './useLongPressHighlightPosition'; @@ -85,6 +86,8 @@ const useHighlight = ({ }); const addHighlightByDrag = () => { + trackEventInAmplitude(HIGHLIGHT_EVENT_NAME.addHighlightByDrag); + const selectionInfo = findSelectionInfo(); if (!selectionInfo) return; const newEditorAnswerMap: EditorAnswerMap | undefined = selectionInfo.isSameAnswer @@ -221,6 +224,8 @@ const useHighlight = ({ }; const removeHighlightByDrag = () => { + trackEventInAmplitude(HIGHLIGHT_EVENT_NAME.removeHighlightByDrag); + const selectionInfo = findSelectionInfo(); if (!selectionInfo) return; @@ -404,6 +409,8 @@ const useHighlight = ({ }; const removeHighlightByLongPress = async () => { + trackEventInAmplitude(HIGHLIGHT_EVENT_NAME.removeHighlightByLongPress); + if (!longPressRemovalTarget) return; const { answerId, lineIndex, highlightIndex } = longPressRemovalTarget; diff --git a/frontend/src/components/layouts/ReviewDisplayLayout/hooks/useReviewDisplayLayoutOptions/index.ts b/frontend/src/components/layouts/ReviewDisplayLayout/hooks/useReviewDisplayLayoutOptions/index.ts index 61c3583fd..39053e2ee 100644 --- a/frontend/src/components/layouts/ReviewDisplayLayout/hooks/useReviewDisplayLayoutOptions/index.ts +++ b/frontend/src/components/layouts/ReviewDisplayLayout/hooks/useReviewDisplayLayoutOptions/index.ts @@ -1,8 +1,11 @@ +import { useCallback } from 'react'; import { useLocation, useNavigate } from 'react-router'; import { OptionSwitchOption } from '@/components/common/OptionSwitch'; +import { COLLECTION_LIST_SWITCH_EVENT_NAME } from '@/constants'; import { ROUTE } from '@/constants/route'; import { useSearchParamAndQuery } from '@/hooks'; +import { trackEventInAmplitude } from '@/utils'; const useReviewDisplayLayoutOptions = () => { const { pathname } = useLocation(); @@ -14,16 +17,28 @@ const useReviewDisplayLayoutOptions = () => { const isReviewCollection = pathname.includes(ROUTE.reviewCollection); + const navigatePage = useCallback(() => { + // 리뷰 리스트로 이동 + if (isReviewCollection) { + trackEventInAmplitude(COLLECTION_LIST_SWITCH_EVENT_NAME.list); + navigate(`/${ROUTE.reviewList}/${reviewRequestCode}`); + return; + } + // 리뷰 모아보기로 이동 + trackEventInAmplitude(COLLECTION_LIST_SWITCH_EVENT_NAME.collection); + navigate(`/${ROUTE.reviewCollection}/${reviewRequestCode}`); + }, [isReviewCollection]); + const reviewDisplayLayoutOptions: OptionSwitchOption[] = [ { label: '목록보기', isChecked: !isReviewCollection, - handleOptionClick: () => navigate(`/${ROUTE.reviewList}/${reviewRequestCode}`), + handleOptionClick: navigatePage, }, { label: '모아보기', isChecked: isReviewCollection, - handleOptionClick: () => navigate(`/${ROUTE.reviewCollection}/${reviewRequestCode}`), + handleOptionClick: navigatePage, }, ]; diff --git a/frontend/src/constants/amplitudeEventName.ts b/frontend/src/constants/amplitudeEventName.ts new file mode 100644 index 000000000..c610d53b7 --- /dev/null +++ b/frontend/src/constants/amplitudeEventName.ts @@ -0,0 +1,35 @@ +import { PageName } from '@/types'; + +export const HIGHLIGHT_EVENT_NAME = { + addHighlightByDrag: '드래그를 통한 형광펜 추가', + removeHighlightByDrag: '드래그를 통한 형광펜 삭제', + removeHighlightByLongPress: '길게 눌러서 형광펜 영역 삭제', + openHighlightEditor: '형광펜 에디터 열기', +}; + +export const COLLECTION_EVENT_NAME = { + switchCardSection: '리뷰 모아보기-리뷰 카드 섹션 변경', +}; + +export const COLLECTION_LIST_SWITCH_EVENT_NAME = { + collection: '스위치 버튼으로 리뷰 모아보기 열기', + list: '스위치 버튼으로 리뷰 목록 열기', +}; + +export const PAGE_VISITED_EVENT_NAME: { [key in Exclude]: string } = { + home: '[page] 홈 페이지', + reviewZone: '[page] 리뷰 연결 페이지', + reviewList: '[page] 리뷰 목록 페이지', + reviewCollection: '[page] 리뷰 모아보기 페이지', + detailedReview: '[page] 리뷰 상세 보기 페이지', + reviewWriting: '[page] 리뷰 작성 페이지', + reviewWritingComplete: '[page] 리뷰 작성 완료 페이지', +}; + +export const REVIEW_WRITING_EVENT_NAME = { + submitReview: '리뷰 제출', +}; + +export const HOM_EVENT_NAME = { + generateReviewURL: '리뷰 URL 생성', +}; diff --git a/frontend/src/constants/index.ts b/frontend/src/constants/index.ts index 3238057ed..3aae448f5 100644 --- a/frontend/src/constants/index.ts +++ b/frontend/src/constants/index.ts @@ -5,3 +5,4 @@ export * from './queryKey'; export * from './routerParam'; export * from './highlight'; export * from './sessionStorageKey'; +export * from './amplitudeEventName'; diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index 5532776bd..2735d6761 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -6,6 +6,7 @@ export { default as useDropdown } from './useDropdown'; export { default as useAccordion } from './useAccordion'; export { default as useBreadcrumbPaths } from './useBreadcrumbPaths'; export { default as useTopButton } from './useTopButton'; +export { default as useTrackVisitedPageInAmplitude } from './useTrackVisitedPageInAmplitude'; export * from './review'; export * from './reviewGroup'; export * from './modal'; diff --git a/frontend/src/hooks/useTrackVisitedPageInAmplitude.ts b/frontend/src/hooks/useTrackVisitedPageInAmplitude.ts new file mode 100644 index 000000000..d34d2f38b --- /dev/null +++ b/frontend/src/hooks/useTrackVisitedPageInAmplitude.ts @@ -0,0 +1,36 @@ +import { useCallback, useEffect } from 'react'; +import { useLocation } from 'react-router'; + +import { PAGE_VISITED_EVENT_NAME } from '@/constants'; +import { ROUTE } from '@/constants/route'; +import { PageName } from '@/types'; +import { trackEventInAmplitude } from '@/utils'; + +const useTrackVisitedPageInAmplitude = () => { + const location = useLocation(); + + const getPageName = useCallback((): PageName => { + const { home, reviewWritingComplete, ...rest } = ROUTE; + + if (location.pathname === home) return 'home'; + if (location.pathname.includes(reviewWritingComplete)) return 'reviewWritingComplete'; + + const pageName = Object.entries(rest).find(([key, value]) => location.pathname.includes(value))?.[0] as PageName; + + return pageName; + }, [location.pathname]); + + const trackVisitedPageInAmplitude = (pageName: PageName) => { + if (!pageName) return console.error('페이지 이름을 찾을 수 없어요.'); + + const eventName = PAGE_VISITED_EVENT_NAME[pageName]; + trackEventInAmplitude(eventName); + }; + + useEffect(() => { + const pageName = getPageName(); + trackVisitedPageInAmplitude(pageName); + }, [getPageName]); +}; + +export default useTrackVisitedPageInAmplitude; diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index 732d14e5d..452794fef 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -1,117 +1,35 @@ import { Global, ThemeProvider } from '@emotion/react'; -import * as Sentry from '@sentry/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import React, { lazy, Suspense } from 'react'; +import React from 'react'; import ReactDOM from 'react-dom/client'; -import { createBrowserRouter, RouterProvider } from 'react-router-dom'; +import { RouterProvider } from 'react-router-dom'; import { RecoilRoot } from 'recoil'; -import App from '@/App'; - -import { ErrorSuspenseContainer } from './components'; -import { API_ERROR_MESSAGE, ROUTE_PARAM } from './constants'; -import { ROUTE } from './constants/route'; +import router from './router'; import globalStyles from './styles/globalStyles'; import theme from './styles/theme'; +import { initializeSentry, retryQuery, startAmplitude, startMockWorker } from './utils'; -const isProduction = process.env.NODE_ENV === 'production'; -const baseUrlPattern = new RegExp(`^${process.env.API_BASE_URL?.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&')}`); - -const HomePage = lazy(() => import('@/pages/HomePage')); -const DetailedReviewPage = lazy(() => import('@/pages/DetailedReviewPage')); -const ErrorPage = lazy(() => import('@/pages/ErrorPage')); -const ReviewListPage = lazy(() => import('@/pages/ReviewListPage')); -const ReviewWritingCompletePage = lazy(() => import('@/pages/ReviewWritingCompletePage')); -const ReviewWritingPage = lazy(() => import('@/pages/ReviewWritingPage')); -const ReviewZonePage = lazy(() => import('@/pages/ReviewZonePage')); -const ReviewCollectionPage = lazy(() => import('@/pages/ReviewCollectionPage')); - -const LoadingPage = lazy(() => import('@/pages/LoadingPage')); - -Sentry.init({ - dsn: `${process.env.SENTRY_DSN}`, - enabled: isProduction, - integrations: [Sentry.browserTracingIntegration()], - environment: 'production', - tracesSampleRate: 1.0, - tracePropagationTargets: [baseUrlPattern], -}); - -export function retryFunction(failureCount: number, error: Error): boolean { - const { message } = error; - const isServerError = message === API_ERROR_MESSAGE.serverError; - - // Fetch API로 인해 발생한 오류인지 확인 - // 500번대 에러이면 한 번 더 재시도 - if (isServerError) return failureCount < 1; - - return false; // 그 외의 경우 재시도하지 않음 -} +initializeSentry(); +startAmplitude(); const queryClient = new QueryClient({ defaultOptions: { queries: { throwOnError: true, - retry: retryFunction, + retry: retryQuery, refetchOnWindowFocus: false, }, mutations: { throwOnError: true, - retry: retryFunction, + retry: retryQuery, }, }, }); -const router = createBrowserRouter([ - { - path: ROUTE.home, - element: ( - }> - - - ), - errorElement: , - children: [ - { - path: '', - element: , - }, - { path: `${ROUTE.reviewWriting}/:${ROUTE_PARAM.reviewRequestCode}`, element: }, - { - path: `${ROUTE.reviewWritingComplete}/:${ROUTE_PARAM.reviewRequestCode}`, - element: , - }, - { - path: `${ROUTE.reviewList}/:${ROUTE_PARAM.reviewRequestCode}`, - element: , - }, - { - path: `${ROUTE.detailedReview}/:${ROUTE_PARAM.reviewRequestCode}/:${ROUTE_PARAM.reviewId}`, - element: , - }, - { - path: `${ROUTE.reviewZone}/:${ROUTE_PARAM.reviewRequestCode}`, - element: ( - - - - ), - }, - { path: `${ROUTE.reviewCollection}/:${ROUTE_PARAM.reviewRequestCode}`, element: }, - ], - }, -]); - const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); -async function enableMocking() { - if (!isProduction) { - const { worker } = await import('./mocks/browser'); - return worker.start(); - } -} - -enableMocking().then(() => { +startMockWorker().then(() => { root.render( diff --git a/frontend/src/pages/HomePage/components/URLGeneratorForm/index.tsx b/frontend/src/pages/HomePage/components/URLGeneratorForm/index.tsx index eae13403d..63a18c6c6 100644 --- a/frontend/src/pages/HomePage/components/URLGeneratorForm/index.tsx +++ b/frontend/src/pages/HomePage/components/URLGeneratorForm/index.tsx @@ -2,10 +2,11 @@ import { useId, useState } from 'react'; import { DataForReviewRequestCode } from '@/apis/group'; import { Button } from '@/components'; +import { HOM_EVENT_NAME } from '@/constants'; import { ROUTE } from '@/constants/route'; import { useModals } from '@/hooks'; import { isValidPasswordInput, isValidReviewGroupDataInput } from '@/pages/HomePage/utils/validateInput'; -import { debounce } from '@/utils'; +import { debounce, trackEventInAmplitude } from '@/utils'; import usePostDataForReviewRequestCode from '../../hooks/usePostDataForReviewRequestCode'; import { FormLayout, ReviewZoneURLModal } from '../index'; @@ -43,8 +44,9 @@ const URLGeneratorForm = () => { isValidPasswordInput(password); const postDataForURL = () => { - const dataForReviewRequestCode: DataForReviewRequestCode = { revieweeName, projectName, groupAccessCode: password }; + trackEventInAmplitude(HOM_EVENT_NAME.generateReviewURL); + const dataForReviewRequestCode: DataForReviewRequestCode = { revieweeName, projectName, groupAccessCode: password }; mutation.mutate(dataForReviewRequestCode, { onSuccess: (data) => { const completeReviewZoneURL = getCompleteReviewZoneURL(data.reviewRequestCode); diff --git a/frontend/src/pages/ReviewWritingPage/modals/components/SubmitCheckModal/index.tsx b/frontend/src/pages/ReviewWritingPage/modals/components/SubmitCheckModal/index.tsx index b8ff38425..9e7f04005 100644 --- a/frontend/src/pages/ReviewWritingPage/modals/components/SubmitCheckModal/index.tsx +++ b/frontend/src/pages/ReviewWritingPage/modals/components/SubmitCheckModal/index.tsx @@ -1,5 +1,7 @@ import { ConfirmModal } from '@/components'; +import { REVIEW_WRITING_EVENT_NAME } from '@/constants'; import { useSubmitAnswers } from '@/pages/ReviewWritingPage/form/hooks'; +import { trackEventInAmplitude } from '@/utils'; import * as S from './style'; @@ -11,9 +13,14 @@ interface SubmitCheckModalProps { const SubmitCheckModal = ({ handleCancelButtonClick, handleCloseModal }: SubmitCheckModalProps) => { const { submitAnswers } = useSubmitAnswers({ closeSubmitConfirmModal: handleCloseModal }); + const handleConfirmButtonClick = (event: React.MouseEvent) => { + trackEventInAmplitude(REVIEW_WRITING_EVENT_NAME.submitReview); + submitAnswers(event); + }; + return ( import('@/pages/HomePage')); +const DetailedReviewPage = lazy(() => import('@/pages/DetailedReviewPage')); +const ErrorPage = lazy(() => import('@/pages/ErrorPage')); +const ReviewListPage = lazy(() => import('@/pages/ReviewListPage')); +const ReviewWritingCompletePage = lazy(() => import('@/pages/ReviewWritingCompletePage')); +const ReviewWritingPage = lazy(() => import('@/pages/ReviewWritingPage')); +const ReviewZonePage = lazy(() => import('@/pages/ReviewZonePage')); +const ReviewCollectionPage = lazy(() => import('@/pages/ReviewCollectionPage')); +const LoadingPage = lazy(() => import('@/pages/LoadingPage')); + +import App from './App'; +import { ErrorSuspenseContainer } from './components'; +import { ROUTE_PARAM } from './constants'; +import { ROUTE } from './constants/route'; + +const router = createBrowserRouter([ + { + path: ROUTE.home, + element: ( + }> + + + ), + errorElement: , + children: [ + { + path: '', + element: , + }, + { path: `${ROUTE.reviewWriting}/:${ROUTE_PARAM.reviewRequestCode}`, element: }, + { + path: `${ROUTE.reviewWritingComplete}/:${ROUTE_PARAM.reviewRequestCode}`, + element: , + }, + { + path: `${ROUTE.reviewList}/:${ROUTE_PARAM.reviewRequestCode}`, + element: , + }, + { + path: `${ROUTE.detailedReview}/:${ROUTE_PARAM.reviewRequestCode}/:${ROUTE_PARAM.reviewId}`, + element: , + }, + { + path: `${ROUTE.reviewZone}/:${ROUTE_PARAM.reviewRequestCode}`, + element: ( + + + + ), + }, + { path: `${ROUTE.reviewCollection}/:${ROUTE_PARAM.reviewRequestCode}`, element: }, + ], + }, +]); + +export default router; diff --git a/frontend/src/types/amplitude.ts b/frontend/src/types/amplitude.ts new file mode 100644 index 000000000..bd7452f66 --- /dev/null +++ b/frontend/src/types/amplitude.ts @@ -0,0 +1,3 @@ +import { ROUTE } from '@/constants/route'; + +export type PageName = keyof typeof ROUTE | undefined; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 3388936fc..274e390d9 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -5,3 +5,4 @@ export * from './styles'; export * from './essentialPropsWithChildren'; export * from './reviewGroup'; export * from './highlight'; +export * from './amplitude'; diff --git a/frontend/src/utils/analytics/amplitude.ts b/frontend/src/utils/analytics/amplitude.ts new file mode 100644 index 000000000..4e732265f --- /dev/null +++ b/frontend/src/utils/analytics/amplitude.ts @@ -0,0 +1,33 @@ +import * as amplitude from '@amplitude/analytics-browser'; + +export const startAmplitude = () => { + if (!process.env.AMPLITUDE_KEY) return; + + amplitude.init(process.env.AMPLITUDE_KEY, { autocapture: false }); +}; + +/** + * 사용자가 사용한 이벤트를 추적하는 메서드 + * @param eventName 이벤트 이름 + * @param eventProps 사용자 행동 데이터에 추가적으로 들어갈 내용들 + */ +export const trackEventInAmplitude = (eventName: string, eventProps: Record = {}) => { + if (!process.env.AMPLITUDE_KEY) return; + + const PATHNAME = { + release: 'review-me.page', + dev: 'dev.review-me.page', + }; + const DOMAIN_MAPPING = { + [PATHNAME.release]: 'release', + [PATHNAME.dev]: 'dev', + }; + + const { hostname } = window.location; + const domainName = DOMAIN_MAPPING[hostname] || 'local'; + + amplitude.track(eventName, { + domain: domainName, + ...eventProps, + }); +}; diff --git a/frontend/src/utils/analytics/index.ts b/frontend/src/utils/analytics/index.ts new file mode 100644 index 000000000..d39ea44b9 --- /dev/null +++ b/frontend/src/utils/analytics/index.ts @@ -0,0 +1,2 @@ +export { default as initializeSentry } from './sentry'; +export * from './amplitude'; diff --git a/frontend/src/utils/analytics/sentry.ts b/frontend/src/utils/analytics/sentry.ts new file mode 100644 index 000000000..39e2d45f3 --- /dev/null +++ b/frontend/src/utils/analytics/sentry.ts @@ -0,0 +1,17 @@ +import * as Sentry from '@sentry/react'; + +const isProduction = process.env.NODE_ENV === 'production'; +const baseUrlPattern = new RegExp(`^${process.env.API_BASE_URL?.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&')}`); + +const initializeSentry = () => { + Sentry.init({ + dsn: `${process.env.SENTRY_DSN}`, + enabled: isProduction, + integrations: [Sentry.browserTracingIntegration()], + environment: 'production', + tracesSampleRate: 1.0, + tracePropagationTargets: [baseUrlPattern], + }); +}; + +export default initializeSentry; diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts index 8f6ac7b77..4864f6b9a 100644 --- a/frontend/src/utils/index.ts +++ b/frontend/src/utils/index.ts @@ -5,7 +5,10 @@ export { default as hasFinalConsonant } from './hasFinalConsonant'; export { default as substituteString } from './substituteString'; export { default as calculateParticle } from './calculateParticle'; export { default as isTouchDevice } from './touchDevice'; +export { default as retryQuery } from './queryRetrier'; +export { default as startMockWorker } from './mockWorkerStarter'; export * from './date'; export * from './media'; export * from './highlight/index'; export * from './testUtils'; +export * from './analytics'; diff --git a/frontend/src/utils/mockWorkerStarter.ts b/frontend/src/utils/mockWorkerStarter.ts new file mode 100644 index 000000000..bb46a4820 --- /dev/null +++ b/frontend/src/utils/mockWorkerStarter.ts @@ -0,0 +1,8 @@ +const startMockWorker = async () => { + if (process.env.NODE_ENV === 'production') return; + + const { worker } = await import('../mocks/browser'); + worker.start(); +}; + +export default startMockWorker; diff --git a/frontend/src/utils/queryRetrier.ts b/frontend/src/utils/queryRetrier.ts new file mode 100644 index 000000000..3e83fdd32 --- /dev/null +++ b/frontend/src/utils/queryRetrier.ts @@ -0,0 +1,14 @@ +import { API_ERROR_MESSAGE } from '@/constants'; + +const retryQuery = (failureCount: number, error: Error): boolean => { + const { message } = error; + const isServerError = message === API_ERROR_MESSAGE.serverError; + + // Fetch API로 인해 발생한 오류인지 확인 + // 500번대 에러이면 한 번 더 재시도 + if (isServerError) return failureCount < 1; + + return false; // 그 외의 경우 재시도하지 않음 +}; + +export default retryQuery; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index c852a9af3..0fa40d901 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -7,6 +7,76 @@ resolved "https://registry.yarnpkg.com/@adobe/css-tools/-/css-tools-4.4.0.tgz#728c484f4e10df03d5a3acd0d8adcbbebff8ad63" integrity sha512-Ff9+ksdQQB3rMncgqDK78uLznstjyfIf2Arnh22pW8kBpLs6rpKDwgnZT46hin5Hl1WzazzK64DOrhSwYpS7bQ== +"@amplitude/analytics-browser@^2.11.8": + version "2.11.8" + resolved "https://registry.yarnpkg.com/@amplitude/analytics-browser/-/analytics-browser-2.11.8.tgz#ab084a0b1e647ed3ceb4ba112d97cf25ddb58684" + integrity sha512-lFv8deROLwBfSlg92+r1NitWJ6BN45IKwpPLoixA0fZytScXEJqc0Gl5O+BY4qScbFECYt9PFKblhB+jC+IvPg== + dependencies: + "@amplitude/analytics-client-common" "^2.3.4" + "@amplitude/analytics-core" "^2.5.3" + "@amplitude/analytics-remote-config" "^0.4.0" + "@amplitude/analytics-types" "^2.8.3" + "@amplitude/plugin-autocapture-browser" "^1.0.2" + "@amplitude/plugin-page-view-tracking-browser" "^2.3.4" + tslib "^2.4.1" + +"@amplitude/analytics-client-common@>=1 <3", "@amplitude/analytics-client-common@^2.3.4": + version "2.3.4" + resolved "https://registry.yarnpkg.com/@amplitude/analytics-client-common/-/analytics-client-common-2.3.4.tgz#c17c853e2d7c0158f8fdbbc514973df9ab57db0d" + integrity sha512-3oqdvca5W4BPblTaxf60YRtlh2uC+N3rA99wowDAhTBJoMJJaauOBoXu5BbiQO1u8Zw/c8ymyr8E20+glyptUg== + dependencies: + "@amplitude/analytics-connector" "^1.4.8" + "@amplitude/analytics-core" "^2.5.3" + "@amplitude/analytics-types" "^2.8.3" + tslib "^2.4.1" + +"@amplitude/analytics-connector@^1.4.8": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@amplitude/analytics-connector/-/analytics-connector-1.5.0.tgz#89a78b8c6463abe4de1d621db4af6c62f0d62b0a" + integrity sha512-T8mOYzB9RRxckzhL0NTHwdge9xuFxXEOplC8B1Y3UX3NHa3BLh7DlBUZlCOwQgMc2nxDfnSweDL5S3bhC+W90g== + +"@amplitude/analytics-core@>=1 <3", "@amplitude/analytics-core@^2.5.3": + version "2.5.3" + resolved "https://registry.yarnpkg.com/@amplitude/analytics-core/-/analytics-core-2.5.3.tgz#73eabb5b79bf76bc4a5b519f75340791caba1697" + integrity sha512-dvx3PS0adnHRS22VbuP9YtWg//bQGF2c61Pj5IYXVsemtRRHqiS7XJ860brk3WeQgOkqf3Gyc023DoYcsWGoNQ== + dependencies: + "@amplitude/analytics-types" "^2.8.3" + tslib "^2.4.1" + +"@amplitude/analytics-remote-config@^0.4.0": + version "0.4.1" + resolved "https://registry.yarnpkg.com/@amplitude/analytics-remote-config/-/analytics-remote-config-0.4.1.tgz#b62cf8aa82290f68b314197e20351b10ea44ae3e" + integrity sha512-BYl6kQ9qjztrCACsugpxO+foLaQIC0aSEzoXEAb/gwOzInmqkyyI+Ub+aWTBih4xgB/lhWlOcidWHAmNiTJTNw== + dependencies: + "@amplitude/analytics-client-common" ">=1 <3" + "@amplitude/analytics-core" ">=1 <3" + "@amplitude/analytics-types" ">=1 <3" + tslib "^2.4.1" + +"@amplitude/analytics-types@>=1 <3", "@amplitude/analytics-types@^2.8.2", "@amplitude/analytics-types@^2.8.3": + version "2.8.3" + resolved "https://registry.yarnpkg.com/@amplitude/analytics-types/-/analytics-types-2.8.3.tgz#a5a4e1d6f1f02bc2c17d9460c95fc0e449f94351" + integrity sha512-HNmKVd0ACoi3xTi86xi+is7WgqKT78JA4fYLcM25/ckFkZ1zVCqD1AubaADEh26m34nJ3qDLK5Pob4QptQNPAg== + +"@amplitude/plugin-autocapture-browser@^1.0.2": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@amplitude/plugin-autocapture-browser/-/plugin-autocapture-browser-1.0.3.tgz#cd14a1a5f10a570f1e2b08465e42bc4d38eee0b5" + integrity sha512-XUQWUAw9VqtJPlmOyWjnhsEspyVakd9LuSjVNtLjhwlWv+f/yZM1AAQVUdq/Os1+b5OptSgJQ2pPfRJJHZDXTw== + dependencies: + "@amplitude/analytics-client-common" ">=1 <3" + "@amplitude/analytics-types" "^2.8.2" + rxjs "^7.8.1" + tslib "^2.4.1" + +"@amplitude/plugin-page-view-tracking-browser@^2.3.4": + version "2.3.4" + resolved "https://registry.yarnpkg.com/@amplitude/plugin-page-view-tracking-browser/-/plugin-page-view-tracking-browser-2.3.4.tgz#96002cb4331d5c3e5fe7ea70f73ca274bce0d600" + integrity sha512-l7RS5gssG0BPYlgirV0NQ94EPzTOdDkp0z2jqU45D3DQAJXkoloUyw5lw/cbUXYwNulHZTG/BExcERfdvVWkLA== + dependencies: + "@amplitude/analytics-client-common" "^2.3.4" + "@amplitude/analytics-types" "^2.8.3" + tslib "^2.4.1" + "@ampproject/remapping@^2.2.0": version "2.3.0" resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.3.0.tgz#ed441b6fa600072520ce18b43d2c8cc8caecc7f4" @@ -7077,6 +7147,13 @@ run-parallel@^1.1.9: dependencies: queue-microtask "^1.2.2" +rxjs@^7.8.1: + version "7.8.1" + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.8.1.tgz#6f6f3d99ea8044291efd92e7c7fcf562c4057543" + integrity sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg== + dependencies: + tslib "^2.1.0" + safe-array-concat@^1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/safe-array-concat/-/safe-array-concat-1.1.2.tgz#81d77ee0c4e8b863635227c721278dd524c20edb" @@ -7823,6 +7900,11 @@ tslib@^2.0.3: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.3.tgz#0438f810ad7a9edcde7a241c3d80db693c8cbfe0" integrity sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ== +tslib@^2.1.0, tslib@^2.4.1: + version "2.8.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.0.tgz#d124c86c3c05a40a91e6fdea4021bd31d377971b" + integrity sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA== + type-check@^0.4.0, type-check@~0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" From d6e27b20fddadcc2f87d226b05b494ac41209f06 Mon Sep 17 00:00:00 2001 From: sooyeon Date: Tue, 22 Oct 2024 20:10:48 +0900 Subject: [PATCH 42/49] =?UTF-8?q?[FE]=20feat:=20=ED=98=95=EA=B4=91?= =?UTF-8?q?=ED=8E=9C=20=EC=B6=94=EA=B0=80=20=EB=98=90=EB=8A=94=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EC=8B=A4=ED=8C=A8=20=EC=8B=9C,=20Toast=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=ED=91=9C=EC=8B=9C=20(#879)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 형광펜 관련 api 파일명 변경(collection -> highlight) * feat: 인증 쿠키 확인하는 목 핸들러 생성 및 적용 * feat: 커스텀한 ErrorBoudary 생성 및 react-error-boundary 삭제 * feat: postHighlight에서 응답이 없는 api 오류도 잡을 수 있도록 수정 * feat: HighlightEditorContainer 생성 - 이유 : 형광펜 api 오류 시, 모아보기 페이지의 ErrorBoundary가 잡으면, 아코디언 상태가 초기화 되어서 HighlightEditor를 별도의 ErrorBoundary로 잡도록 함 * refactor: ErrorBoundary의 fallback 조건문 리팩토링 * feat: postMockHighlight 가 쿠키 인증 하도록 기능 추가 * feat:useMutation을 사용해 형광펜 API 다루도록 함 * feat: 커스텀한 ErrorBoudary 생성 및 react-error-boundary 삭제 * refactor: fallback 조건문 리팩토링 * refactor: 주석 수정 * feat: ErrorBoundary resetQueryError props 타입 변경 * feat: HighlightEditorContainer에 ErrorSuspenseContainer > ErrorBoundary로 변경 * feat: resetQueryError 타입 변경 * feat: 하이라이트 api 요청 데이터 유효성 검사 추가 * test: 하이라이트 api 요청 테스트 추가 * chore: 불필요한 코드 삭제 * test: 형광펜 api 테스트 리팩토링 * refactor: 불필요한 주석 삭제 및 변수명,함수명 변경 * feat: 쿠키 인증 필요한 테스트에서 쿠키 생성 및 삭제하는 유틸 함수 구현 및 적용 * refactor: 테스트용 유틸 폴더 구조 변경 * chore: EditorTestPage 삭제 * refactor: 하이라이트 훅, 컴포넌트 폴더 구조 변경 * refactor: 하이라이트 훅을 HighlightEditor 아래로 위치 이동 및 하이라이트 컴포넌트 export 위치 변경 - HighlightEditorContainer를 src/components/index.tsx에서 export 하도록 수정 * refactor: ErrorBoundary 폴더 구조 변경 * refactor: line 관련 변수명 변경 (blockElement -> lineElement) * feat: HighlightMenu 컴포넌트 훅 생성 및 기존 관련 코드 변경 * fix: 하이라이트 적용과 미적용 같이 있을 때, isForwardDrag 값 오류 수정 * refactor: 하이라이트 메뉴 사이즈 관련 변수 수정 * design: 하이라이트 메뉴 버튼 스타일 변경 (사이즈 조정 및 hover시 배경색 변경) * fix: 모아보기 페이지- 주관식 답변 개행에도 li marker 표시되는 오류 수정 * refactor: 하이라이트 메뉴 위치 초기화하는 함수명 변경 * fix: 길게 눌렀을 때 삭제되는 버튼 안 뜨는 오류 수정 및 상태명 변경 * feat: 형광펜 기능 알려주는 툴팁 구현 * chore: 불필요한 콘솔 삭제 * fix: useMutateHighlight 테스트 오류 수정 * design: 형광펜 스위치 버튼 디자인 수정 - 꺼짐 : 버튼 왼쪽 - 켜짐: 버튼 오른쪽 * chore: 파일 위치 변경 * fix: 리뷰 모아보기 데이터 타입 차이로 인한, 형광펜 적용 안되는 오류 수정 원인 :서버가 내려준 데이터 타입과 클라이언트 타입 불일치 * design: EditorLineBlock 스타일 컴포넌트 삭제 * chore: 리뷰 모아보기 목 데이터 변경(형광펜 적용 추가) * feat: Toast 컴포넌트 구현 및 적용 * chore: 불필요한 코드 삭제 * feat: 형광펜 추가 혹은 삭제 실패 시, 토스트 메시지 변경 * refactor: Toast 속도 조정 * chore: Toast 파일 위치 변경 * refactor: toast 사라진 후, 메시지 초기화 설정 * chore: toast 위치 및 box-shadow 조정 * refactor: ModalPortal에 disableScroll props를 추가해서 스크롤 막는 기능을 제어 * chore: toast 인터페이스 이름 변경 * chore: Toast export 추가 * chore: 충돌 해결 과정에서 나타난 반복되는 코드 제거 * chore: ModalPortal을 Portal로 네이밍 변경 * chore: 사용되지 않는 import 제거 * chore: Portal 위치 이동 --------- Co-authored-by: badahertz52 --- frontend/src/assets/dot.svg | 9 ++ frontend/src/assets/warning.svg | 9 ++ .../{modals/ModalPortal => Portal}/index.tsx | 13 +- .../{modals/ModalPortal => Portal}/styles.ts | 2 +- .../src/components/common/Toast/index.tsx | 43 +++++++ .../src/components/common/Toast/styles.ts | 121 ++++++++++++++++++ frontend/src/components/common/index.tsx | 1 + .../common/modals/AlertModal/index.tsx | 6 +- .../common/modals/ConfirmModal/index.tsx | 6 +- .../common/modals/ContentModal/index.tsx | 6 +- .../hooks/useDragHighlightPosition.ts | 1 - .../HighlightEditor/hooks/useHighlight.ts | 23 +++- .../hooks/useHighlightRemoverPosition.ts | 45 +++++++ .../hooks/useHighlightToggleButtonPosition.ts | 75 +++++++++++ .../components/HighlightEditor/index.tsx | 4 +- .../HighlightEditorContainer/index.tsx | 20 ++- .../HighlightToggleButtonContainer/index.tsx | 31 +++++ frontend/src/types/highlight.ts | 1 + 18 files changed, 393 insertions(+), 23 deletions(-) create mode 100644 frontend/src/assets/dot.svg create mode 100644 frontend/src/assets/warning.svg rename frontend/src/components/common/{modals/ModalPortal => Portal}/index.tsx (50%) rename frontend/src/components/common/{modals/ModalPortal => Portal}/styles.ts (82%) create mode 100644 frontend/src/components/common/Toast/index.tsx create mode 100644 frontend/src/components/common/Toast/styles.ts create mode 100644 frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlightRemoverPosition.ts create mode 100644 frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlightToggleButtonPosition.ts create mode 100644 frontend/src/components/highlight/components/HighlightToggleButtonContainer/index.tsx diff --git a/frontend/src/assets/dot.svg b/frontend/src/assets/dot.svg new file mode 100644 index 000000000..94f39074f --- /dev/null +++ b/frontend/src/assets/dot.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/src/assets/warning.svg b/frontend/src/assets/warning.svg new file mode 100644 index 000000000..739fd389d --- /dev/null +++ b/frontend/src/assets/warning.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/src/components/common/modals/ModalPortal/index.tsx b/frontend/src/components/common/Portal/index.tsx similarity index 50% rename from frontend/src/components/common/modals/ModalPortal/index.tsx rename to frontend/src/components/common/Portal/index.tsx index 2e243a1e3..f0ddf1daa 100644 --- a/frontend/src/components/common/modals/ModalPortal/index.tsx +++ b/frontend/src/components/common/Portal/index.tsx @@ -3,11 +3,12 @@ import { createPortal } from 'react-dom'; import * as S from './styles'; -interface ModalPortalProps { +interface PortalProps { id?: string; + disableScroll?: boolean; } -const ModalPortal: React.FC> = ({ children: Modal, id }) => { +const Portal: React.FC> = ({ children: Modal, id, disableScroll = true }) => { const preventBodyScroll = () => { document.body.style.overflow = 'hidden'; }; @@ -17,14 +18,14 @@ const ModalPortal: React.FC> = ({ children: }; useEffect(() => { - preventBodyScroll(); + if (disableScroll) preventBodyScroll(); return () => { - allowBodyScroll(); + if (disableScroll) allowBodyScroll(); }; }); - return createPortal({Modal}, document.body); + return createPortal({Modal}, document.body); }; -export default ModalPortal; +export default Portal; diff --git a/frontend/src/components/common/modals/ModalPortal/styles.ts b/frontend/src/components/common/Portal/styles.ts similarity index 82% rename from frontend/src/components/common/modals/ModalPortal/styles.ts rename to frontend/src/components/common/Portal/styles.ts index 609144d8e..71865a228 100644 --- a/frontend/src/components/common/modals/ModalPortal/styles.ts +++ b/frontend/src/components/common/Portal/styles.ts @@ -1,6 +1,6 @@ import styled from '@emotion/styled'; -export const ModalPortal = styled.div` +export const Portal = styled.div` position: fixed; z-index: ${({ theme }) => theme.zIndex.modal}; top: 0; diff --git a/frontend/src/components/common/Toast/index.tsx b/frontend/src/components/common/Toast/index.tsx new file mode 100644 index 000000000..d9219477e --- /dev/null +++ b/frontend/src/components/common/Toast/index.tsx @@ -0,0 +1,43 @@ +import { useEffect } from 'react'; + +import Portal from '../Portal'; + +import * as S from './styles'; + +export type ToastPositionType = 'top' | 'bottom'; + +interface IconProps { + src: string; + alt: string; +} + +interface ToastProps { + icon?: IconProps; + message: string; + duration: number; + position: ToastPositionType; + handleOpenModal: (isOpen: boolean) => void; + handleModalMessage: (message: string) => void; +} + +const Toast = ({ icon, message, duration, position, handleOpenModal, handleModalMessage }: ToastProps) => { + useEffect(() => { + const timer = setTimeout(() => { + handleOpenModal(false); + handleModalMessage(''); + }, duration * 1000); + + return () => clearTimeout(timer); + }, [handleOpenModal]); + + return ( + + + {icon && } + {message} + + + ); +}; + +export default Toast; diff --git a/frontend/src/components/common/Toast/styles.ts b/frontend/src/components/common/Toast/styles.ts new file mode 100644 index 000000000..0c7a64ee8 --- /dev/null +++ b/frontend/src/components/common/Toast/styles.ts @@ -0,0 +1,121 @@ +import { css, keyframes } from '@emotion/react'; +import styled from '@emotion/styled'; + +import media from '@/utils/media'; + +import { ToastPositionType } from '.'; + +interface ToastModalProps { + duration: number; + position: ToastPositionType; +} + +// 위에서 아래로 내려오는 애니메이션 +const fadeInDown = keyframes` + 0% { + opacity: 0; + transform: translate(-50%, 0); + } + 100% { + opacity: 1; + transform: translate(-50%, 100%); + } +`; + +// 아래에서 다시 위로 올라가는 애니메이션 +const fadeOutUp = keyframes` + 0% { + opacity: 1; + transform: translate(-50%, 100%); + } + 100% { + opacity: 0; + transform: translate(-50%, 0); + } +`; + +// 아래에서 위로 올라오는 애니메이션 +const fadeInUp = keyframes` + 0% { + opacity: 0; + transform: translate(-50%, 100%); + } + 100% { + opacity: 1; + transform: translate(-50%, 0); + } +`; + +// 위에서 아래로 내려가는 애니메이션 +const fadeOutDown = keyframes` + 0% { + opacity: 1; + transform: translate(-50%, 0); + } + 100% { + opacity: 0; + transform: translate(-50%, 100%); + } +`; + +const getToastPositionStyles = (position: ToastPositionType, duration: number) => { + return css` + ${position === 'top' && + css` + top: 5%; + animation: + ${fadeInDown} 0.5s ease-out forwards, + ${fadeOutUp} 0.5s ease-out forwards; + animation-delay: 0s, ${duration - 0.5}s; + `} + + ${position === 'bottom' && + css` + bottom: 5%; + animation: + ${fadeInUp} 0.5s ease-out forwards, + ${fadeOutDown} 0.5s ease-out forwards; + animation-delay: 0s, ${duration - 0.5}s; + `} + `; +}; + +export const ToastModalContainer = styled.div` + background-color: #626262; + color: white; + display: flex; + justify-content: center; + align-items: center; + gap: 0.8rem; + + z-index: ${({ theme }) => theme.zIndex.modal}; + + position: fixed; + + ${({ position, duration }) => getToastPositionStyles(position, duration)} + + left: 50%; + transform: translateX(-50%); + + padding: 1rem 3rem; + + font-size: ${({ theme }) => theme.fontSize.small}; + + border-radius: ${({ theme }) => theme.borderRadius.basic}; + box-shadow: 0.4rem 0.4rem 0.8rem rgba(0, 0, 0, 0.2); + + border: none; + + ${media.xxSmall} { + padding: 1rem 2rem; + } +`; + +export const WarningIcon = styled.img` + width: 2rem; + height: 2rem; +`; + +export const ErrorMessage = styled.span` + white-space: nowrap; +`; diff --git a/frontend/src/components/common/index.tsx b/frontend/src/components/common/index.tsx index 3fe540459..2a15f749f 100644 --- a/frontend/src/components/common/index.tsx +++ b/frontend/src/components/common/index.tsx @@ -9,6 +9,7 @@ export { default as EyeButton } from './EyeButton'; export { default as Carousel } from './Carousel'; export { default as Accordion } from './Accordion'; export { default as Dropdown } from './Dropdown'; +export { default as Toast } from './Toast'; export { default as OptionSwitch } from './OptionSwitch'; export { default as ReviewEmptySection } from './ReviewEmptySection'; diff --git a/frontend/src/components/common/modals/AlertModal/index.tsx b/frontend/src/components/common/modals/AlertModal/index.tsx index 44691bc17..635dc5782 100644 --- a/frontend/src/components/common/modals/AlertModal/index.tsx +++ b/frontend/src/components/common/modals/AlertModal/index.tsx @@ -1,8 +1,8 @@ import { ButtonStyleType, EssentialPropsWithChildren } from '@/types'; import Button from '../../Button'; +import Portal from '../../Portal'; import ModalBackground from '../ModalBackground'; -import ModalPortal from '../ModalPortal'; import * as S from './styles'; @@ -25,7 +25,7 @@ const AlertModal = ({ children, }: EssentialPropsWithChildren) => { return ( - + {children} @@ -38,7 +38,7 @@ const AlertModal = ({ - + ); }; diff --git a/frontend/src/components/common/modals/ConfirmModal/index.tsx b/frontend/src/components/common/modals/ConfirmModal/index.tsx index a1b3ee1cc..9f6ad6754 100644 --- a/frontend/src/components/common/modals/ConfirmModal/index.tsx +++ b/frontend/src/components/common/modals/ConfirmModal/index.tsx @@ -3,8 +3,8 @@ import React from 'react'; import { ButtonStyleType } from '@/types'; import Button from '../../Button'; +import Portal from '../../Portal'; import ModalBackground from '../ModalBackground'; -import ModalPortal from '../ModalPortal'; import * as S from './styles'; @@ -32,7 +32,7 @@ const ConfirmModal: React.FC> = ({ }) => { const buttonList = [cancelButton, confirmButton]; return ( - + {children} @@ -45,7 +45,7 @@ const ConfirmModal: React.FC> = ({ - + ); }; diff --git a/frontend/src/components/common/modals/ContentModal/index.tsx b/frontend/src/components/common/modals/ContentModal/index.tsx index b1a2deb35..79a57d3f3 100644 --- a/frontend/src/components/common/modals/ContentModal/index.tsx +++ b/frontend/src/components/common/modals/ContentModal/index.tsx @@ -1,8 +1,8 @@ import CloseIcon from '@/assets/x.svg'; import { EssentialPropsWithChildren } from '@/types'; +import Portal from '../../Portal'; import ModalBackground from '../ModalBackground'; -import ModalPortal from '../ModalPortal'; import * as S from './styles'; @@ -24,7 +24,7 @@ const ContentModal = ({ isClosableOnBackground = true, }: EssentialPropsWithChildren) => { return ( - + @@ -36,7 +36,7 @@ const ContentModal = ({ {children} - + ); }; diff --git a/frontend/src/components/highlight/components/HighlightEditor/hooks/useDragHighlightPosition.ts b/frontend/src/components/highlight/components/HighlightEditor/hooks/useDragHighlightPosition.ts index b212bf589..ac127fe03 100644 --- a/frontend/src/components/highlight/components/HighlightEditor/hooks/useDragHighlightPosition.ts +++ b/frontend/src/components/highlight/components/HighlightEditor/hooks/useDragHighlightPosition.ts @@ -108,7 +108,6 @@ const useDragHighlightPosition = ({ editorRect: DOMRect; lastRect: DOMRect; } - const calculateDragHighlightMenuPosition = ({ leftOffsetFromEditor, topOffsetFromEditor, diff --git a/frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlight.ts b/frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlight.ts index 1f286b992..366226269 100644 --- a/frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlight.ts +++ b/frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlight.ts @@ -21,6 +21,7 @@ interface UseHighlightProps extends UseLongPressHighlightPositionReturn { answerList: ReviewAnswerResponseData[]; isEditable: boolean; handleErrorModal: (isError: boolean) => void; + handleModalMessage: (message: string) => void; resetHighlightMenuPosition: () => void; } interface RemovalTarget { @@ -29,9 +30,15 @@ interface RemovalTarget { highlightIndex: number; } +const HIGHLIGHT_ERROR_MESSAGES = { + addFailure: '형광펜 추가에 실패했어요. 다시 시도해주세요.', + deleteFailure: '형광펜 삭제에 실패했어요. 다시 시도해주세요.', +}; + const findBlockHighlightListFromAnswer = (answerHighlightList: HighlightResponseData[], lineIndex: number) => { return answerHighlightList.find((i) => i.lineIndex === lineIndex)?.ranges || []; }; + const makeBlockListByText = (content: string, answerHighlightList: HighlightResponseData[]): EditorLine[] => { return content.split('\n').map((text, index) => ({ lineIndex: index, @@ -62,6 +69,7 @@ const useHighlight = ({ updateHighlightMenuPositionByLongPress, resetHighlightMenuPosition, handleErrorModal, + handleModalMessage, }: UseHighlightProps) => { const [editorAnswerMap, setEditorAnswerMap] = useState(makeInitialEditorAnswerMap(answerList)); @@ -95,7 +103,12 @@ const useHighlight = ({ : addMultipleAnswerHighlight(selectionInfo); if (!newEditorAnswerMap) return; - mutateHighlight(newEditorAnswerMap); + console.log('new', newEditorAnswerMap.get(2)); + mutateHighlight(newEditorAnswerMap, { + onError: () => { + handleModalMessage(HIGHLIGHT_ERROR_MESSAGES.addFailure); + }, + }); }; const addMultipleAnswerHighlight = (selectionInfo: SelectionInfo) => { @@ -253,7 +266,7 @@ const useHighlight = ({ if (index > endLineIndex) return line; if (index === startLineIndex) { const { startIndex, endIndex } = getStartLineOffset(selectionInfo, line); - + console.log(startIndex, endIndex); return { ...line, highlightList: getRemovedHighlightList({ @@ -429,7 +442,11 @@ const useHighlight = ({ newLineList.splice(lineIndex, 1, newTargetBlock); newEditorAnswerMap.set(answerId, { ...targetAnswer, lineList: newLineList }); - mutateHighlight(newEditorAnswerMap); + mutateHighlight(newEditorAnswerMap, { + onError: () => { + handleModalMessage(HIGHLIGHT_ERROR_MESSAGES.deleteFailure); + }, + }); }; return { diff --git a/frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlightRemoverPosition.ts b/frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlightRemoverPosition.ts new file mode 100644 index 000000000..eecfed581 --- /dev/null +++ b/frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlightRemoverPosition.ts @@ -0,0 +1,45 @@ +import { useLayoutEffect, useState } from 'react'; + +import { GAP_WIDTH_SELECTION_AND_HIGHLIGHT_BUTTON, HIGHLIGHT_BUTTON_SIZE } from '@/constants'; +import { Position } from '@/types'; + +interface UseHighlightRemoverPositionProps { + isEditable: boolean; + editorRef: React.RefObject; +} +const useHighlightRemoverPosition = ({ isEditable, editorRef }: UseHighlightRemoverPositionProps) => { + const [removerPosition, setRemoverPosition] = useState(null); + + const updateRemoverPosition = (rect: DOMRect) => { + const editorRect = editorRef.current?.getClientRects()[0]; + if (!editorRect) return; + const top = rect.bottom - editorRect.top; + const left = rect.right - editorRect.left; + + const buttonWidth = HIGHLIGHT_BUTTON_SIZE.width.basic; + + const isOverEditorArea = editorRect.right < rect.right + buttonWidth; + const topOffsetFromParent = isOverEditorArea ? top + GAP_WIDTH_SELECTION_AND_HIGHLIGHT_BUTTON : top; + const leftOffsetFromParent = isOverEditorArea ? editorRect.width - buttonWidth : left; + + setRemoverPosition({ + top: ` + ${topOffsetFromParent / 10}rem`, + left: `${leftOffsetFromParent / 10}rem`, + }); + }; + + const hideRemover = () => setRemoverPosition(null); + + useLayoutEffect(() => { + if (!isEditable) hideRemover(); + }, [isEditable]); + + return { + removerPosition, + updateRemoverPosition, + hideRemover, + }; +}; + +export default useHighlightRemoverPosition; diff --git a/frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlightToggleButtonPosition.ts b/frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlightToggleButtonPosition.ts new file mode 100644 index 000000000..806c3285e --- /dev/null +++ b/frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlightToggleButtonPosition.ts @@ -0,0 +1,75 @@ +import { useLayoutEffect, useState } from 'react'; + +import { GAP_WIDTH_SELECTION_AND_HIGHLIGHT_BUTTON, HIGHLIGHT_BUTTON_SIZE } from '@/constants'; +import { Position } from '@/types'; +import { EditorSelectionInfo } from '@/utils'; + +interface UseHighlightButtonPositionProps { + isEditable: boolean; + editorRef: React.RefObject; +} + +const useHighlightToggleButtonPosition = ({ isEditable, editorRef }: UseHighlightButtonPositionProps) => { + const [highlightToggleButtonPosition, setHighlightToggleButtonPosition] = useState(null); + + const hideHighlightToggleButton = () => setHighlightToggleButtonPosition(null); + + interface CalculateEndPositionParams { + info: EditorSelectionInfo; + isAddingHighlight: boolean; + } + const calculateEndPosition = ({ info, isAddingHighlight }: CalculateEndPositionParams) => { + const { selection, isForwardDrag, startBlock } = info; + if (!editorRef.current) return; + const range = selection.getRangeAt(0); + const rects = range.getClientRects(); + const editorRect = editorRef.current.getClientRects()[0]; + + if (rects.length === 0) return; + + // 드래그 방향에 따른 마지막 rect의 좌표 정보를 가져옴 (마우스가 놓인 최종 지점) + const lastRect = rects[isForwardDrag ? rects.length - 1 : 0]; + const buttonHight = HIGHLIGHT_BUTTON_SIZE.height; + const { basic: buttonBasicWidth, buttonWidthColor: addButtonWidth } = HIGHLIGHT_BUTTON_SIZE.width; + const buttonWidth = isAddingHighlight ? addButtonWidth : buttonBasicWidth; + + const rectLeft = isForwardDrag ? lastRect.right : lastRect.left; + const left = rectLeft - editorRect.left; + const top = + lastRect.top - + (isForwardDrag ? 0 : startBlock.clientHeight + buttonHight + GAP_WIDTH_SELECTION_AND_HIGHLIGHT_BUTTON) - + editorRect.top + + buttonHight; + + const isOverEditorArea = editorRect.right < rectLeft + buttonWidth; + const leftOffsetFromParent = isOverEditorArea ? editorRect.width - buttonWidth : left; + const topOffsetFromParent = top; + const endPosition: Position = { + left: `${leftOffsetFromParent / 10}rem`, + top: `${topOffsetFromParent / 10}rem`, + }; + + return endPosition; + }; + + const updateHighlightToggleButtonPosition = ({ info, isAddingHighlight }: CalculateEndPositionParams) => { + const endPosition = calculateEndPosition({ info, isAddingHighlight }); + if (!endPosition) return console.error('endPosition을 찾을 수 없어요.'); + + setHighlightToggleButtonPosition(endPosition); + }; + + useLayoutEffect(() => { + if (!isEditable) hideHighlightToggleButton(); + }, [isEditable]); + + useLayoutEffect(() => {}); + + return { + highlightToggleButtonPosition, + hideHighlightToggleButton, + updateHighlightToggleButtonPosition, + }; +}; + +export default useHighlightToggleButtonPosition; diff --git a/frontend/src/components/highlight/components/HighlightEditor/index.tsx b/frontend/src/components/highlight/components/HighlightEditor/index.tsx index fbec2c3da..34f0145b3 100644 --- a/frontend/src/components/highlight/components/HighlightEditor/index.tsx +++ b/frontend/src/components/highlight/components/HighlightEditor/index.tsx @@ -16,9 +16,10 @@ export interface HighlightEditorProps { questionId: number; answerList: ReviewAnswerResponseData[]; handleErrorModal: (isError: boolean) => void; + handleModalMessage: (message: string) => void; } -const HighlightEditor = ({ questionId, answerList, handleErrorModal }: HighlightEditorProps) => { +const HighlightEditor = ({ questionId, answerList, handleErrorModal, handleModalMessage }: HighlightEditorProps) => { const editorRef = useRef(null); const { isEditable, handleEditToggleButton } = useEditableState(); @@ -50,6 +51,7 @@ const HighlightEditor = ({ questionId, answerList, handleErrorModal }: Highlight resetHighlightMenuPosition, updateHighlightMenuPositionByLongPress, handleErrorModal, + handleModalMessage, }); const { startPressTimer, clearPressTimer } = useLongPress({ handleLongPress: handleLongPressLine }); diff --git a/frontend/src/components/highlight/components/HighlightEditorContainer/index.tsx b/frontend/src/components/highlight/components/HighlightEditorContainer/index.tsx index 66f37a005..c35e5b816 100644 --- a/frontend/src/components/highlight/components/HighlightEditorContainer/index.tsx +++ b/frontend/src/components/highlight/components/HighlightEditorContainer/index.tsx @@ -1,18 +1,34 @@ import { useState } from 'react'; +import WarningIcon from '@/assets/warning.svg'; +import Toast from '@/components/common/Toast'; + import { ErrorBoundary } from '../../../error'; import ErrorFallback from '../../../error/ErrorFallback'; import HighlightEditor, { HighlightEditorProps } from '../HighlightEditor'; const HighlightEditorContainer = (props: Omit) => { const [isOpenErrorModal, setIsOpenErrorModal] = useState(false); + const [modalMessage, setModalMessage] = useState(''); + const handleErrorModal = (isError: boolean) => setIsOpenErrorModal(isError); + const handleModalMessage = (message: string) => setModalMessage(message); + return ( <> - + - {isOpenErrorModal &&
오류
} + {isOpenErrorModal && ( + + )} ); }; diff --git a/frontend/src/components/highlight/components/HighlightToggleButtonContainer/index.tsx b/frontend/src/components/highlight/components/HighlightToggleButtonContainer/index.tsx new file mode 100644 index 000000000..6830091cb --- /dev/null +++ b/frontend/src/components/highlight/components/HighlightToggleButtonContainer/index.tsx @@ -0,0 +1,31 @@ +import { Position } from '@/types'; + +import HighlightButton from '../HighlightButton'; + +interface HighlightToggleButtonContainerProps { + buttonPosition: Position; + isAddingHighlight: boolean; + addHighlight: () => void; + removeHighlightByDrag: () => void; +} +/** + *선택된 영역의 하이라이트 적용 여부에 따라 추가 또는 삭제 버튼을 보여주는 컴포넌트 + */ +const HighlightToggleButtonContainer = ({ + buttonPosition, + isAddingHighlight, + addHighlight, + removeHighlightByDrag, +}: HighlightToggleButtonContainerProps) => { + return ( + <> + {isAddingHighlight ? ( + + ) : ( + + )} + + ); +}; + +export default HighlightToggleButtonContainer; diff --git a/frontend/src/types/highlight.ts b/frontend/src/types/highlight.ts index c94deaafc..b436779e3 100644 --- a/frontend/src/types/highlight.ts +++ b/frontend/src/types/highlight.ts @@ -13,6 +13,7 @@ export interface HighlightResponseData { lineIndex: number; ranges: HighlightRange[]; } + // 서버에서 보내주는 리뷰 모아보기 데이터 export interface ReviewAnswerResponseData { id: number; From 3108538177ffe7c6d0e8d4412b47d75f7db40114 Mon Sep 17 00:00:00 2001 From: badahertz52 Date: Wed, 23 Oct 2024 10:38:35 +0900 Subject: [PATCH 43/49] =?UTF-8?q?fix:=20=EB=A6=AC=EB=B7=B0=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D,=EB=A6=AC=EB=B7=B0=20=EB=AA=A8=EC=95=84=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0=20=EC=8A=A4=EC=9C=84=EC=B9=98=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0=20(#902)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../useReviewDisplayLayoutOptions/index.ts | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/layouts/ReviewDisplayLayout/hooks/useReviewDisplayLayoutOptions/index.ts b/frontend/src/components/layouts/ReviewDisplayLayout/hooks/useReviewDisplayLayoutOptions/index.ts index 39053e2ee..427aa8f55 100644 --- a/frontend/src/components/layouts/ReviewDisplayLayout/hooks/useReviewDisplayLayoutOptions/index.ts +++ b/frontend/src/components/layouts/ReviewDisplayLayout/hooks/useReviewDisplayLayoutOptions/index.ts @@ -1,4 +1,3 @@ -import { useCallback } from 'react'; import { useLocation, useNavigate } from 'react-router'; import { OptionSwitchOption } from '@/components/common/OptionSwitch'; @@ -17,28 +16,26 @@ const useReviewDisplayLayoutOptions = () => { const isReviewCollection = pathname.includes(ROUTE.reviewCollection); - const navigatePage = useCallback(() => { - // 리뷰 리스트로 이동 - if (isReviewCollection) { - trackEventInAmplitude(COLLECTION_LIST_SWITCH_EVENT_NAME.list); - navigate(`/${ROUTE.reviewList}/${reviewRequestCode}`); - return; - } - // 리뷰 모아보기로 이동 + const navigateReviewListPage = () => { + trackEventInAmplitude(COLLECTION_LIST_SWITCH_EVENT_NAME.list); + navigate(`/${ROUTE.reviewList}/${reviewRequestCode}`); + }; + + const navigateReviewCollectionPage = () => { trackEventInAmplitude(COLLECTION_LIST_SWITCH_EVENT_NAME.collection); navigate(`/${ROUTE.reviewCollection}/${reviewRequestCode}`); - }, [isReviewCollection]); + }; const reviewDisplayLayoutOptions: OptionSwitchOption[] = [ { label: '목록보기', isChecked: !isReviewCollection, - handleOptionClick: navigatePage, + handleOptionClick: navigateReviewListPage, }, { label: '모아보기', isChecked: isReviewCollection, - handleOptionClick: navigatePage, + handleOptionClick: navigateReviewCollectionPage, }, ]; From 390683f13943cc4f7e31de124f6e094fa23a92df Mon Sep 17 00:00:00 2001 From: sooyeon Date: Wed, 23 Oct 2024 10:39:16 +0900 Subject: [PATCH 44/49] =?UTF-8?q?[FE]=20fix:=20Toast=EA=B0=80=20=EB=82=98?= =?UTF-8?q?=ED=83=80=EB=82=9C=20=ED=9B=84=20=ED=81=B4=EB=A6=AD=20=EB=B0=8F?= =?UTF-8?q?=20=EB=93=9C=EB=9E=98=EA=B7=B8=20=EB=B6=88=EA=B0=80=20=EC=9D=B4?= =?UTF-8?q?=EC=8A=88=20=ED=95=B4=EA=B2=B0=20(#905)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: Portal에 pointer-events 속성 추가 * chore: stylelint로 css 속성 순서 조정 * fix: 형광펜 삭제 실패 시, toast에 에러 메시지 보여주지 않는 이슈 해결 --- frontend/src/components/common/Portal/index.tsx | 9 +++++++-- frontend/src/components/common/Portal/styles.ts | 6 +++++- frontend/src/components/common/Toast/styles.ts | 6 ++---- .../HighlightEditor/hooks/useHighlight.ts | 6 +++++- .../components/DoughnutChart/styles.ts | 5 ++--- .../components/DoughnutChartDetails/styles.ts | 14 +++++--------- 6 files changed, 26 insertions(+), 20 deletions(-) diff --git a/frontend/src/components/common/Portal/index.tsx b/frontend/src/components/common/Portal/index.tsx index f0ddf1daa..a43f4f706 100644 --- a/frontend/src/components/common/Portal/index.tsx +++ b/frontend/src/components/common/Portal/index.tsx @@ -3,7 +3,7 @@ import { createPortal } from 'react-dom'; import * as S from './styles'; -interface PortalProps { +export interface PortalProps { id?: string; disableScroll?: boolean; } @@ -25,7 +25,12 @@ const Portal: React.FC> = ({ children: Modal, id, }; }); - return createPortal({Modal}, document.body); + return createPortal( + + {Modal} + , + document.body, + ); }; export default Portal; diff --git a/frontend/src/components/common/Portal/styles.ts b/frontend/src/components/common/Portal/styles.ts index 71865a228..f8d58d997 100644 --- a/frontend/src/components/common/Portal/styles.ts +++ b/frontend/src/components/common/Portal/styles.ts @@ -1,6 +1,10 @@ import styled from '@emotion/styled'; -export const Portal = styled.div` +import { PortalProps } from '.'; + +export const Portal = styled.div` + pointer-events: ${({ disableScroll }) => (disableScroll ? 'auto' : 'none')}; + position: fixed; z-index: ${({ theme }) => theme.zIndex.modal}; top: 0; diff --git a/frontend/src/components/common/Toast/styles.ts b/frontend/src/components/common/Toast/styles.ts index 0c7a64ee8..02dd2c252 100644 --- a/frontend/src/components/common/Toast/styles.ts +++ b/frontend/src/components/common/Toast/styles.ts @@ -82,18 +82,17 @@ const getToastPositionStyles = (position: ToastPositionType, duration: number) = export const ToastModalContainer = styled.div` background-color: #626262; + color: white; + display: flex; justify-content: center; align-items: center; gap: 0.8rem; - z-index: ${({ theme }) => theme.zIndex.modal}; - position: fixed; ${({ position, duration }) => getToastPositionStyles(position, duration)} - left: 50%; transform: translateX(-50%); @@ -103,7 +102,6 @@ export const ToastModalContainer = styled.div` border-radius: ${({ theme }) => theme.borderRadius.basic}; box-shadow: 0.4rem 0.4rem 0.8rem rgba(0, 0, 0, 0.2); - border: none; ${media.xxSmall} { diff --git a/frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlight.ts b/frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlight.ts index 366226269..76104b8ba 100644 --- a/frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlight.ts +++ b/frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlight.ts @@ -248,7 +248,11 @@ const useHighlight = ({ if (!newEditorAnswerMap) return; - mutateHighlight(newEditorAnswerMap); + mutateHighlight(newEditorAnswerMap, { + onError: () => { + handleModalMessage(HIGHLIGHT_ERROR_MESSAGES.deleteFailure); + }, + }); }; const removeSingleAnswerHighlight = (selectionInfo: SelectionInfo) => { diff --git a/frontend/src/pages/ReviewCollectionPage/components/DoughnutChart/styles.ts b/frontend/src/pages/ReviewCollectionPage/components/DoughnutChart/styles.ts index af15be420..3acfc66cd 100644 --- a/frontend/src/pages/ReviewCollectionPage/components/DoughnutChart/styles.ts +++ b/frontend/src/pages/ReviewCollectionPage/components/DoughnutChart/styles.ts @@ -4,10 +4,9 @@ import media from '@/utils/media'; export const DoughnutChartContainer = styled.div` display: flex; - justify-content: center; - align-items: center; - gap: 5rem; + align-items: center; + justify-content: center; ${media.small} { flex-direction: column; diff --git a/frontend/src/pages/ReviewCollectionPage/components/DoughnutChartDetails/styles.ts b/frontend/src/pages/ReviewCollectionPage/components/DoughnutChartDetails/styles.ts index 899ae23b6..47bd2c547 100644 --- a/frontend/src/pages/ReviewCollectionPage/components/DoughnutChartDetails/styles.ts +++ b/frontend/src/pages/ReviewCollectionPage/components/DoughnutChartDetails/styles.ts @@ -5,9 +5,7 @@ import media from '@/utils/media'; export const DoughnutChartDetailList = styled.div` display: flex; flex-direction: column; - gap: 2rem; - margin: 2rem; ${media.small} { @@ -17,27 +15,25 @@ export const DoughnutChartDetailList = styled.div` export const DetailItem = styled.div` display: flex; - justify-content: space-between; - align-items: center; - gap: 1rem; + align-items: center; + justify-content: space-between; `; export const ContentContainer = styled.div` display: flex; - align-items: center; - gap: 1rem; + align-items: center; `; export const ChartColor = styled.div<{ color: string }>` - background-color: ${({ color }) => color}; + flex-shrink: 0; width: 2rem; height: 2rem; + background-color: ${({ color }) => color}; border-radius: 0.5rem; - flex-shrink: 0; ${media.small} { width: 1.6rem; From 06da0b58ae657427e0a60cd24192175ab6f11ca8 Mon Sep 17 00:00:00 2001 From: badahertz52 Date: Wed, 23 Oct 2024 11:10:21 +0900 Subject: [PATCH 45/49] =?UTF-8?q?[FE]=20fix=20:=20=20=ED=98=95=EA=B4=91?= =?UTF-8?q?=ED=8E=9C=20endIndex=20=EC=98=A4=EB=A5=98=20=EB=B0=8F=20api=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=8B=9C=20isEditable=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=88=98=EC=A0=95=20(#907)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * design: 공백인 개행을 위한 문자열을 위해 Line의 min-height 추가 * feat: 공백으로 이루어진 개행용 문자열의 highlightList를 빈 배열로 유지 및 리뷰 모아보기 목 데이터 변경 - 리뷰 모아보기 목 데이터 변경 :공백으로 이어진 개행용 문자열 추가 * chore: 주석에 내용 추가 * fix: 형광펜 api 오류 시, isEditable 저장 안되는 오류 수정 - 세션 스토리지가 아닌 로컬 스토리지에서 저장 - LOCAL_STORAGE_KEY.isHighlightEditable을 HighlightEditorCotainer 언마운트 시 삭제하도록 함 * chore: 형광펜 목 핸들러 반환값 복구 --- frontend/src/apis/highlight.ts | 2 +- .../components/EditorLineBlock/style.ts | 1 + .../HighlightEditor/hooks/useEditableState.ts | 24 ++++++------- .../HighlightEditor/hooks/useHighlight.ts | 34 ++++++++++++++----- .../hooks/useMutateHighlight/index.ts | 6 ++-- .../HighlightEditorContainer/index.tsx | 9 ++++- frontend/src/constants/index.ts | 2 +- .../{sessionStorageKey.ts => storageKey.ts} | 2 +- .../src/mocks/mockData/reviewCollection.ts | 2 +- frontend/src/types/highlight.ts | 2 +- 10 files changed, 53 insertions(+), 31 deletions(-) rename frontend/src/constants/{sessionStorageKey.ts => storageKey.ts} (70%) diff --git a/frontend/src/apis/highlight.ts b/frontend/src/apis/highlight.ts index 151109a93..00a7dd4a9 100644 --- a/frontend/src/apis/highlight.ts +++ b/frontend/src/apis/highlight.ts @@ -5,7 +5,7 @@ import createApiErrorMessage from './apiErrorMessageCreator'; import endPoint from './endpoints'; export const transformHighlightData = (editorAnswerMap: EditorAnswerMap, questionId: number): HighlightPostPayload => { - // NOTE: 하이라이트가 있는 답변만 서버에 보내줌 + // NOTE: 하이라이트가 있는 답변만 서버에 보내줌 (줄에 하이라이트가 없으면 빈배열) return { questionId, highlights: [...editorAnswerMap.values()] diff --git a/frontend/src/components/highlight/components/EditorLineBlock/style.ts b/frontend/src/components/highlight/components/EditorLineBlock/style.ts index 789fa6ace..5396f3a83 100644 --- a/frontend/src/components/highlight/components/EditorLineBlock/style.ts +++ b/frontend/src/components/highlight/components/EditorLineBlock/style.ts @@ -1,6 +1,7 @@ import styled from '@emotion/styled'; export const Line = styled.p` + min-height: calc(${({ theme }) => theme.fontSize.basic} * 1.5); word-break: break-all; overflow-wrap: break-word; white-space: normal; diff --git a/frontend/src/components/highlight/components/HighlightEditor/hooks/useEditableState.ts b/frontend/src/components/highlight/components/HighlightEditor/hooks/useEditableState.ts index d865220be..b08eb1f77 100644 --- a/frontend/src/components/highlight/components/HighlightEditor/hooks/useEditableState.ts +++ b/frontend/src/components/highlight/components/HighlightEditor/hooks/useEditableState.ts @@ -1,19 +1,19 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useLayoutEffect, useState } from 'react'; -import { HIGHLIGHT_EVENT_NAME, SESSION_STORAGE_KEY } from '@/constants'; +import { HIGHLIGHT_EVENT_NAME, LOCAL_STORAGE_KEY } from '@/constants'; import { trackEventInAmplitude } from '@/utils'; const useEditableState = () => { const [isEditable, setIsEditable] = useState(false); - const getHighlightEditorStateInStorage = () => sessionStorage.getItem(SESSION_STORAGE_KEY.isHighlightEditable); + const getHighlightEditorStateInStorage = () => localStorage.getItem(LOCAL_STORAGE_KEY.isHighlightEditable); const saveHighlightEditorStateInStorage = () => { - sessionStorage.setItem(SESSION_STORAGE_KEY.isHighlightEditable, 'true'); + localStorage.setItem(LOCAL_STORAGE_KEY.isHighlightEditable, 'true'); }; const removeHighlightEditorStateFromStorage = () => { - sessionStorage.removeItem(SESSION_STORAGE_KEY.isHighlightEditable); + localStorage.removeItem(LOCAL_STORAGE_KEY.isHighlightEditable); }; const handleEditToggleButton = () => { @@ -26,16 +26,12 @@ const useEditableState = () => { }); }; - useEffect(() => { + useLayoutEffect(() => { const storageItem = getHighlightEditorStateInStorage(); - if (storageItem) setIsEditable(true); - - return () => { - if (sessionStorage.getItem(SESSION_STORAGE_KEY.isHighlightError)) { - return sessionStorage.removeItem(SESSION_STORAGE_KEY.isHighlightError); - } - removeHighlightEditorStateFromStorage(); - }; + if (storageItem) { + setIsEditable(true); + } + localStorage.removeItem(LOCAL_STORAGE_KEY.isHighlightError); }, []); return { diff --git a/frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlight.ts b/frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlight.ts index 76104b8ba..07df35f5b 100644 --- a/frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlight.ts +++ b/frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlight.ts @@ -103,13 +103,13 @@ const useHighlight = ({ : addMultipleAnswerHighlight(selectionInfo); if (!newEditorAnswerMap) return; - console.log('new', newEditorAnswerMap.get(2)); mutateHighlight(newEditorAnswerMap, { onError: () => { handleModalMessage(HIGHLIGHT_ERROR_MESSAGES.addFailure); }, }); }; + // NOTE :공백으로 이루어진 개행용 문자열의 highlightList는 빈배열로 유지한다 const addMultipleAnswerHighlight = (selectionInfo: SelectionInfo) => { const { startAnswer, endAnswer } = selectionInfo; @@ -125,8 +125,8 @@ const useHighlight = ({ const { lineList } = targetAnswer; const newLineList: EditorLine[] = lineList.map((line, index) => { + if (line.text.trim() === '') return line; if (index < lineIndex) return line; - if (index > lineIndex) { return { ...line, @@ -147,14 +147,17 @@ const useHighlight = ({ if (startAnswer.index < answerIndex && endAnswer.index > answerIndex) { const targetAnswer = newEditorAnswerMap.get(answerId); - if (!targetAnswer) return; const { lineList } = targetAnswer; - const newLineList = lineList.map((line) => ({ - ...line, - highlightList: [{ startIndex: 0, endIndex: line.text.length - 1 }], - })); + const newLineList = lineList.map((line) => { + if (line.text.trim() === '') return line; + + return { + ...line, + highlightList: [{ startIndex: 0, endIndex: line.text.length - 1 }], + }; + }); newEditorAnswerMap.set(answerId, { ...targetAnswer, lineList: newLineList }); } @@ -167,6 +170,7 @@ const useHighlight = ({ const { lineList } = targetAnswer; const newLineList = lineList.map((line, index) => { + if (line.text.trim() === '') return line; if (index > lineIndex) return line; if (index < lineIndex) { return { @@ -202,10 +206,13 @@ const useHighlight = ({ if (!targetAnswer) return; const newLineList: EditorLine[] = targetAnswer.lineList.map((line, index, array) => { + if (line.text.trim() === '') return line; if (index < startLineIndex) return line; if (index > endLineIndex) return line; + if (index === startLineIndex) { const { startIndex, endIndex } = getStartLineOffset(selectionInfo, line); + return getUpdatedBlockByHighlight({ blockTextLength: line.text.length, lineIndex: index, @@ -226,6 +233,7 @@ const useHighlight = ({ lineList: array, }); } + return { ...line, highlightList: [{ startIndex: 0, endIndex: line.text.length - 1 }], @@ -233,6 +241,7 @@ const useHighlight = ({ }); newEditorAnswerMap.set(answerId, { ...targetAnswer, lineList: newLineList }); + return newEditorAnswerMap; }; @@ -266,11 +275,13 @@ const useHighlight = ({ if (!targetAnswer) return; const newLineList = targetAnswer.lineList.map((line, index) => { + if (line.text.trim() === '') return line; if (index < startLineIndex) return line; if (index > endLineIndex) return line; + if (index === startLineIndex) { const { startIndex, endIndex } = getStartLineOffset(selectionInfo, line); - console.log(startIndex, endIndex); + return { ...line, highlightList: getRemovedHighlightList({ @@ -281,6 +292,7 @@ const useHighlight = ({ }), }; } + if (index === endLineIndex) { const endIndex = getEndLineOffset(selectionInfo); return { @@ -300,6 +312,7 @@ const useHighlight = ({ }); newEditorAnswerMap.set(answerId, { ...targetAnswer, lineList: newLineList }); + return newEditorAnswerMap; }; const removeMultipleAnswerHighlight = (selectionInfo: SelectionInfo) => { @@ -316,6 +329,7 @@ const useHighlight = ({ const { lineList } = targetAnswer; const newLineList = lineList.map((line, index) => { + if (line.text.trim() === '') return line; if (index < lineIndex) return line; if (index > lineIndex) { @@ -324,6 +338,7 @@ const useHighlight = ({ highlightList: [], }; } + return { ...line, highlightList: getRemovedHighlightList({ @@ -337,6 +352,7 @@ const useHighlight = ({ newEditorAnswerMap.set(answerId, { ...targetAnswer, lineList: newLineList }); } + if (answerId === endAnswer.id) { const { lineIndex, offset } = endAnswer; const targetAnswer = newEditorAnswerMap.get(answerId); @@ -345,6 +361,7 @@ const useHighlight = ({ const { lineList } = targetAnswer; const newLineList = lineList.map((line, index) => { + if (line.text.trim() === '') return line; if (index > lineIndex) return line; if (index < lineIndex) { @@ -353,6 +370,7 @@ const useHighlight = ({ highlightList: [], }; } + return { ...line, highlightList: getRemovedHighlightList({ diff --git a/frontend/src/components/highlight/components/HighlightEditor/hooks/useMutateHighlight/index.ts b/frontend/src/components/highlight/components/HighlightEditor/hooks/useMutateHighlight/index.ts index 696e01446..56681e41e 100644 --- a/frontend/src/components/highlight/components/HighlightEditor/hooks/useMutateHighlight/index.ts +++ b/frontend/src/components/highlight/components/HighlightEditor/hooks/useMutateHighlight/index.ts @@ -1,7 +1,7 @@ import { useMutation } from '@tanstack/react-query'; import { postHighlight } from '@/apis/highlight'; -import { SESSION_STORAGE_KEY } from '@/constants'; +import { LOCAL_STORAGE_KEY } from '@/constants'; import { EditorAnswerMap } from '@/types'; export interface UseMutateHighlightProps { @@ -27,13 +27,13 @@ const useMutateHighlight = ({ resetHighlightMenu(); // 토스트 모달 지우기 handleErrorModal(false); - sessionStorage.removeItem(SESSION_STORAGE_KEY.isHighlightError); + localStorage.removeItem(LOCAL_STORAGE_KEY.isHighlightError); }, onError: (error) => { //토스트 모달 띄움 handleErrorModal(true); // fallback 실행으로 인한,isEditable 상태 초기화 막음 - sessionStorage.setItem(SESSION_STORAGE_KEY.isHighlightError, 'true'); + localStorage.setItem(LOCAL_STORAGE_KEY.isHighlightError, 'true'); }, }); diff --git a/frontend/src/components/highlight/components/HighlightEditorContainer/index.tsx b/frontend/src/components/highlight/components/HighlightEditorContainer/index.tsx index c35e5b816..01a828e7c 100644 --- a/frontend/src/components/highlight/components/HighlightEditorContainer/index.tsx +++ b/frontend/src/components/highlight/components/HighlightEditorContainer/index.tsx @@ -1,7 +1,8 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import WarningIcon from '@/assets/warning.svg'; import Toast from '@/components/common/Toast'; +import { LOCAL_STORAGE_KEY } from '@/constants'; import { ErrorBoundary } from '../../../error'; import ErrorFallback from '../../../error/ErrorFallback'; @@ -14,6 +15,12 @@ const HighlightEditorContainer = (props: Omit setIsOpenErrorModal(isError); const handleModalMessage = (message: string) => setModalMessage(message); + useEffect(() => { + return () => { + // NOTE: API 오류 시, HighlightEditor가 재렌더링되어서, LOCAL_STORAGE_KEY.isHighlightEditable 삭제되는 것을 막기 위해 HighlightEditorContainer 언마운트 시 삭제해야함 + localStorage.removeItem(LOCAL_STORAGE_KEY.isHighlightEditable); + }; + }, []); return ( <> diff --git a/frontend/src/constants/index.ts b/frontend/src/constants/index.ts index 3aae448f5..4c7bf0c45 100644 --- a/frontend/src/constants/index.ts +++ b/frontend/src/constants/index.ts @@ -4,5 +4,5 @@ export * from './review'; export * from './queryKey'; export * from './routerParam'; export * from './highlight'; -export * from './sessionStorageKey'; +export * from './storageKey'; export * from './amplitudeEventName'; diff --git a/frontend/src/constants/sessionStorageKey.ts b/frontend/src/constants/storageKey.ts similarity index 70% rename from frontend/src/constants/sessionStorageKey.ts rename to frontend/src/constants/storageKey.ts index e6fb46f51..2343b85f2 100644 --- a/frontend/src/constants/sessionStorageKey.ts +++ b/frontend/src/constants/storageKey.ts @@ -1,4 +1,4 @@ -export const SESSION_STORAGE_KEY = { +export const LOCAL_STORAGE_KEY = { isHighlightEditable: 'isHighlightEditable', isHighlightError: 'isHighlightError', }; diff --git a/frontend/src/mocks/mockData/reviewCollection.ts b/frontend/src/mocks/mockData/reviewCollection.ts index 57c096c05..e26afd03b 100644 --- a/frontend/src/mocks/mockData/reviewCollection.ts +++ b/frontend/src/mocks/mockData/reviewCollection.ts @@ -42,7 +42,7 @@ export const GROUPED_REVIEWS_MOCK_DATA: GroupedReviews = { { id: 1, content: - '장의 시작부분은 짧고 직접적이며, 뒤따라 나올 복잡한 정보를 어떻게 해석해야 할 것인지 프레임을 짜주는 역할을 해야 한다. 그러면 아무리 긴 문장이라도 쉽게 읽힌다.\n프레임을 짜주는 역할을 해야 한다. 그러면 아무리 긴 문장이라도 쉽게 읽힌다.\n프레임을 짜주는 역할을 해야 한다. 그러면 아무리 긴 문장이라도 쉽게 읽힌다.', + '장의 시작부분은 짧고 직접적이며, 뒤따라 나올 복잡한 정보를 어떻게 해석해야 할 것인지 프레임을 짜주는 역할을 해야 한다. 그러면 아무리 긴 문장이라도 쉽게 읽힌다.\n\n프레임을 짜주는 역할을 해야 한다.\n \n그러면 아무리 긴 문장이라도 쉽게 읽힌다.\n프레임을 짜주는 역할을 해야 한다. 그러면 아무리 긴 문장이라도 쉽게 읽힌다.', highlights: [{ lineIndex: 0, ranges: [{ startIndex: 0, endIndex: 0 }] }], }, { diff --git a/frontend/src/types/highlight.ts b/frontend/src/types/highlight.ts index b436779e3..a742846e8 100644 --- a/frontend/src/types/highlight.ts +++ b/frontend/src/types/highlight.ts @@ -31,7 +31,7 @@ export interface HighlightPostPayload { //하이라이트가 적용된 블럭의 정보를 보내줌 lines: { index: number; // 하이라이트가 적용된 구문의 index - ranges: HighlightRange[]; + ranges: HighlightRange[]; // 하이라이트가 적용되지 않으면 빈배열 }[]; }[]; } From 77d7ff95e4ac666abe9e80419e8913227d9a6421 Mon Sep 17 00:00:00 2001 From: sooyeon Date: Wed, 23 Oct 2024 13:06:40 +0900 Subject: [PATCH 46/49] =?UTF-8?q?[FE]=20feat:=20=EC=9E=91=EC=84=B1=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=EC=9D=98=20=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?=ED=99=9C=EC=84=B1=ED=99=94=20=EC=83=81=ED=83=9C=20=EB=B0=8F=20?= =?UTF-8?q?=ED=98=84=EC=9E=AC=20=EC=84=B9=EC=85=98=20=EC=A0=95=EB=B3=B4?= =?UTF-8?q?=EB=A5=BC=20=EC=8A=A4=ED=81=AC=EB=A6=B0=20=EB=A6=AC=EB=8D=94?= =?UTF-8?q?=EA=B0=80=20=EC=9D=B8=EC=8B=9D=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20(#883)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: "이전, 다음, 작성 내용 확인, 제출" 버튼에 aria-label 속성 추가 * feat: 모바일 StepButton에 aria-label 속성 추가 * chore: 사용되지 않는 이미지 제거 * feat: 프로그레스 바의 현재 섹션에 대한 aria-label 추가 * refactor: 카드를 넘길 때마다 스크린 리더가 현재 질문 카드의 순번과 이름을 자동으로 읽어주도록 개선 * refactor: 모달이 열리는 버튼에 aria-label 추가 * refactor: 활성화 유무 aria-label 추가 * fix: useEffect 의존성 배열에 stepList 제거 * refactor: 프로그레스 바 버튼의 활성화, 비활성화 상태를 읽어주는 aria-label 추가 * refactor: 다음 버튼의 활성화 상태에 따라 자동으로 읽어주는 aria-live 추가 * refactor: 프로그레스 바 버튼에 disabled 속성 추가 및 현재 선택된 질문 카드 인식을 위한 aria-label 수정 * refactor: 다음 버튼에 aria-live 속성 제거 --- .../components/MobileProgressBar/index.tsx | 13 ++++++++++++- .../components/ProgressBar/index.tsx | 17 ++++++++++++++++- .../components/CardSliderController/index.tsx | 3 +++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/ReviewWritingPage/progressBar/components/MobileProgressBar/index.tsx b/frontend/src/pages/ReviewWritingPage/progressBar/components/MobileProgressBar/index.tsx index c0aa39601..b7d0f1c1f 100644 --- a/frontend/src/pages/ReviewWritingPage/progressBar/components/MobileProgressBar/index.tsx +++ b/frontend/src/pages/ReviewWritingPage/progressBar/components/MobileProgressBar/index.tsx @@ -1,4 +1,4 @@ -import { useLayoutEffect, useRef } from 'react'; +import { useLayoutEffect, useRef, useState } from 'react'; import useStepList from '@/pages/ReviewWritingPage/progressBar/hooks/useStepList'; import { Direction } from '@/pages/ReviewWritingPage/types'; @@ -17,6 +17,8 @@ const MobileProgressBar = ({ currentCardIndex, handleCurrentCardIndex }: MobileP const stepRefs = useRef([]); const animationFrameId = useRef(null); + const [currentCardIndexDescription, setCurrentCardIndexDescription] = useState(''); + useLayoutEffect(() => { if (!progressBarRef.current || !stepRefs.current[currentCardIndex]) return; @@ -30,6 +32,10 @@ const MobileProgressBar = ({ currentCardIndex, handleCurrentCardIndex }: MobileP }, 250); }; + setCurrentCardIndexDescription( + `현재 질문 카드는, 전체 ${stepList.length}개 카드 중, ${currentCardIndex + 1}번째 카드입니다. ${stepList[currentCardIndex].sectionName}`, + ); + animationFrameId.current = requestAnimationFrame(scrollProgressBar); }, [currentCardIndex]); @@ -50,6 +56,8 @@ const MobileProgressBar = ({ currentCardIndex, handleCurrentCardIndex }: MobileP $isCurrentStep={step.isCurrentStep} onClick={() => handleClick(index)} type="button" + aria-label={step.isCurrentStep ? `현재 질문 카드는, ${step.sectionName}입니다` : `${step.sectionName}`} + disabled={!step.isMovingAvailable} > {step.sectionName} @@ -57,6 +65,9 @@ const MobileProgressBar = ({ currentCardIndex, handleCurrentCardIndex }: MobileP ))} + + {currentCardIndexDescription} + ); }; diff --git a/frontend/src/pages/ReviewWritingPage/progressBar/components/ProgressBar/index.tsx b/frontend/src/pages/ReviewWritingPage/progressBar/components/ProgressBar/index.tsx index 06f6e6da5..e88ab0d44 100644 --- a/frontend/src/pages/ReviewWritingPage/progressBar/components/ProgressBar/index.tsx +++ b/frontend/src/pages/ReviewWritingPage/progressBar/components/ProgressBar/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import NavigateNextIcon from '@/assets/navigateNext.svg'; import useStepList from '@/pages/ReviewWritingPage/progressBar/hooks/useStepList'; @@ -14,6 +14,16 @@ interface ProgressBarProps { const ProgressBar = ({ currentCardIndex, handleCurrentCardIndex }: ProgressBarProps) => { const { stepList } = useStepList({ currentCardIndex }); + const [currentCardIndexDescription, setCurrentCardIndexDescription] = useState(''); + + useEffect(() => { + if (stepList.length > 0 && stepList[currentCardIndex]) { + setCurrentCardIndexDescription( + `현재 질문 카드는, 전체 ${stepList.length}개 카드 중, ${currentCardIndex + 1}번째 카드입니다. ${stepList[currentCardIndex].sectionName}`, + ); + } + }, [currentCardIndex]); + const handleClick = (index: number) => { const { isMovingAvailable } = stepList[index]; if (isMovingAvailable) handleCurrentCardIndex(index); @@ -31,6 +41,8 @@ const ProgressBar = ({ currentCardIndex, handleCurrentCardIndex }: ProgressBarPr $isCurrentStep={step.isCurrentStep} onClick={() => handleClick(index)} type="button" + aria-label={step.isCurrentStep ? `현재 질문 카드는, ${step.sectionName}입니다` : `${step.sectionName}`} + disabled={!step.isMovingAvailable} > {step.sectionName} @@ -39,6 +51,9 @@ const ProgressBar = ({ currentCardIndex, handleCurrentCardIndex }: ProgressBarPr ); })} + + {currentCardIndexDescription} + ); }; diff --git a/frontend/src/pages/ReviewWritingPage/slider/components/CardSliderController/index.tsx b/frontend/src/pages/ReviewWritingPage/slider/components/CardSliderController/index.tsx index 00fd731be..45560e5a9 100644 --- a/frontend/src/pages/ReviewWritingPage/slider/components/CardSliderController/index.tsx +++ b/frontend/src/pages/ReviewWritingPage/slider/components/CardSliderController/index.tsx @@ -27,6 +27,7 @@ const NextButton = ({ isAbleNextStep, handleCurrentCardIndex, ...rest }: NextBut styleType={styledType} type={'button'} onClick={() => handleCurrentCardIndex('next')} + aria-label={isAbleNextStep ? '다음 버튼이 활성화되었습니다' : '다음 버튼이 비활성화되었습니다.'} {...rest} > 다음 @@ -50,6 +51,7 @@ const ConfirmModalOpenButton = ({ styleType={styleType} type={'button'} onClick={handleSubmitConfirmModalOpenButtonClick} + aria-label="제출 버튼입니다, 클릭 시 작성한 내용을 제출합니다" {...rest} > 제출 @@ -69,6 +71,7 @@ const RecheckButton = ({ isAbleNextStep, handleRecheckButtonClick, ...rest }: Re styleType={styledType} type={'button'} onClick={handleRecheckButtonClick} + aria-label="작성 내용 확인 버튼입니다, 클릭 시 작성한 내용을 한눈에 볼 수 있는 모달이 열립니다" {...rest} > 작성 내용 확인 From 1570961225fa4198a27dfd1cdb8eb9c851eee4c0 Mon Sep 17 00:00:00 2001 From: Fe <64690761+chysis@users.noreply.github.com> Date: Wed, 23 Oct 2024 13:07:46 +0900 Subject: [PATCH 47/49] =?UTF-8?q?[FE]=20feat:=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC=20=EC=A0=91=EA=B7=BC=EC=84=B1=20=EA=B0=9C=EC=84=A0=20?= =?UTF-8?q?(#906)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * design: 접근성 컴포넌트 전용 스타일 추가 * feat: 키보드 탭과 스크린 리더의 포커스가 모달을 벗어나지 않도록 제한하는 포커스 트랩 구현 * chore: 각 공통 모달 컴포넌트에 포커스 트랩 적용 * feat: 제출 전 확인 모달에서 사용자가 작성한 내용만을 요약해서 읽어주는 기능 구현 --- .../src/components/common/FocusTrap/index.tsx | 89 +++++++++++++++++++ .../src/components/common/FocusTrap/styles.ts | 5 ++ .../common/modals/AlertModal/index.tsx | 23 ++--- .../common/modals/ConfirmModal/index.tsx | 23 ++--- .../common/modals/ContentModal/index.tsx | 21 +++-- .../AnswerListRecheckModal/index.tsx | 24 ++++- 6 files changed, 155 insertions(+), 30 deletions(-) create mode 100644 frontend/src/components/common/FocusTrap/index.tsx create mode 100644 frontend/src/components/common/FocusTrap/styles.ts diff --git a/frontend/src/components/common/FocusTrap/index.tsx b/frontend/src/components/common/FocusTrap/index.tsx new file mode 100644 index 000000000..7454d7865 --- /dev/null +++ b/frontend/src/components/common/FocusTrap/index.tsx @@ -0,0 +1,89 @@ +import { useEffect, useRef, useState } from 'react'; + +import { EssentialPropsWithChildren } from '@/types'; + +import * as S from './styles'; + +const FocusTrap = ({ children }: EssentialPropsWithChildren) => { + const [hasAnnounced, setHasAnnounced] = useState(false); + const focusTrapRef = useRef(null); + + useEffect(() => { + const focusableElements = Array.from( + focusTrapRef.current?.querySelectorAll( + 'a[href], button, textarea, input, select, [tabindex]:not([tabindex="-1"])', + ) || [], + ).filter((element) => { + const el = element as HTMLElement; + // disabled 상태거나 보이지 않는 요소 제외 + return !el.hasAttribute('disabled') && el.offsetParent !== null; + }); + + const firstElement = focusableElements?.[0] as HTMLElement; + const lastElement = focusableElements?.[focusableElements.length - 1] as HTMLElement; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Tab') return; + + if (focusableElements?.length === 0) return; + + if (event.shiftKey) { + if (document.activeElement === firstElement) { + lastElement.focus(); + event.preventDefault(); + } + } else { + if (document.activeElement === lastElement) { + firstElement.focus(); + event.preventDefault(); + } + } + }; + + const handleFocusIn = (e: FocusEvent) => { + if (!focusTrapRef.current?.contains(e.target as Node)) { + firstElement.focus(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + document.addEventListener('focusin', handleFocusIn); + + // 모달을 포함하지 않는 body의 자식들에 aria-hidden 적용 + const bodyChildren = document.body.children; + for (const bodyChildrenElement of bodyChildren) { + if (!bodyChildrenElement.contains(focusTrapRef.current)) { + bodyChildrenElement.setAttribute('aria-hidden', 'true'); + } + } + + // 처음 한 번만 스크린 리더에 안내 문구 제공 + setHasAnnounced(true); + setTimeout(() => { + setHasAnnounced(false); + }, 200); + + return () => { + document.removeEventListener('keydown', handleKeyDown); + document.removeEventListener('focusin', handleFocusIn); + + // 모달이 닫힐 때 aria-hidden 해제 + for (const bodyChildrenElement of bodyChildren) { + bodyChildrenElement.removeAttribute('aria-hidden'); + } + }; + }, []); + + return ( + + {hasAnnounced && ( + + 모달이 열렸습니다 + + )} + {children} + + ); +}; + +export default FocusTrap; diff --git a/frontend/src/components/common/FocusTrap/styles.ts b/frontend/src/components/common/FocusTrap/styles.ts new file mode 100644 index 000000000..631868322 --- /dev/null +++ b/frontend/src/components/common/FocusTrap/styles.ts @@ -0,0 +1,5 @@ +import styled from '@emotion/styled'; + +export const FocusTrapContainer = styled.div` + display: flex; +`; diff --git a/frontend/src/components/common/modals/AlertModal/index.tsx b/frontend/src/components/common/modals/AlertModal/index.tsx index 635dc5782..5fe71e1d7 100644 --- a/frontend/src/components/common/modals/AlertModal/index.tsx +++ b/frontend/src/components/common/modals/AlertModal/index.tsx @@ -1,6 +1,7 @@ import { ButtonStyleType, EssentialPropsWithChildren } from '@/types'; import Button from '../../Button'; +import FocusTrap from '../../FocusTrap'; import Portal from '../../Portal'; import ModalBackground from '../ModalBackground'; @@ -27,16 +28,18 @@ const AlertModal = ({ return ( - - {children} - - + + + {children} + + + ); diff --git a/frontend/src/components/common/modals/ConfirmModal/index.tsx b/frontend/src/components/common/modals/ConfirmModal/index.tsx index 9f6ad6754..e24aa60e5 100644 --- a/frontend/src/components/common/modals/ConfirmModal/index.tsx +++ b/frontend/src/components/common/modals/ConfirmModal/index.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { ButtonStyleType } from '@/types'; import Button from '../../Button'; +import FocusTrap from '../../FocusTrap'; import Portal from '../../Portal'; import ModalBackground from '../ModalBackground'; @@ -34,16 +35,18 @@ const ConfirmModal: React.FC> = ({ return ( - - {children} - - {buttonList.map(({ styleType, type, text, handleClick }) => ( - - ))} - - + + + {children} + + {buttonList.map(({ styleType, type, text, handleClick }) => ( + + ))} + + + ); diff --git a/frontend/src/components/common/modals/ContentModal/index.tsx b/frontend/src/components/common/modals/ContentModal/index.tsx index 79a57d3f3..d0abfca89 100644 --- a/frontend/src/components/common/modals/ContentModal/index.tsx +++ b/frontend/src/components/common/modals/ContentModal/index.tsx @@ -1,6 +1,7 @@ import CloseIcon from '@/assets/x.svg'; import { EssentialPropsWithChildren } from '@/types'; +import FocusTrap from '../../FocusTrap'; import Portal from '../../Portal'; import ModalBackground from '../ModalBackground'; @@ -26,15 +27,17 @@ const ContentModal = ({ return ( - - - {title} - - 모달 닫기 - - - {children} - + + + + {title} + + 모달 닫기 + + + {children} + + ); diff --git a/frontend/src/pages/ReviewWritingPage/modals/components/AnswerListRecheckModal/index.tsx b/frontend/src/pages/ReviewWritingPage/modals/components/AnswerListRecheckModal/index.tsx index 00f96f447..b0350efdf 100644 --- a/frontend/src/pages/ReviewWritingPage/modals/components/AnswerListRecheckModal/index.tsx +++ b/frontend/src/pages/ReviewWritingPage/modals/components/AnswerListRecheckModal/index.tsx @@ -28,9 +28,31 @@ const AnswerListRecheckModal = ({ questionSectionList, answerMap, closeModal }: return answer ? answer.text : ''; }; + const generateSummary = () => { + let summary = '작성한 리뷰 내용입니다.'; + + questionSectionList.forEach((section) => { + section.questions.forEach((question) => { + if (question.questionType === 'CHECKBOX') { + const selectedOptions = question.optionGroup?.options + .filter((option) => isSelectedChoice(question.questionId, option.optionId)) + .map((option) => option.content) + .join(', '); + if (selectedOptions) summary += `질문: ${question.content}. 답변: ${selectedOptions}.`; + } else if (question.questionType === 'TEXT') { + const textAnswer = findTextAnswer(question.questionId); + summary += `질문: ${question.content}. 답변: ${textAnswer ? textAnswer : '답변이 없습니다.'}.`; + } + }); + }); + + return summary; + }; + return ( - + {generateSummary()} + {questionSectionList.map((section) => ( From b49f57aefad078c7bf6d495248a3ecb8dc67c7c3 Mon Sep 17 00:00:00 2001 From: ImxYJL <111052302+ImxYJL@users.noreply.github.com> Date: Wed, 23 Oct 2024 14:57:26 +0900 Subject: [PATCH 48/49] =?UTF-8?q?[FE]=20fix:=20textarea=EA=B0=80=20?= =?UTF-8?q?=EC=97=86=EB=8A=94=20=EB=A6=AC=EB=B7=B0=20=EC=9E=91=EC=84=B1=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=EC=97=90=EC=84=9C=20tab=ED=82=A4=EB=A1=9C=20?= =?UTF-8?q?=EB=8B=A4=EB=A5=B8=20=EB=A6=AC=EB=B7=B0=20=EC=84=B9=EC=85=98?= =?UTF-8?q?=EC=97=90=20=EC=A0=91=EA=B7=BC=ED=95=A0=20=EC=88=98=20=EC=9E=88?= =?UTF-8?q?=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95=20(#914)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: CheckboxItem의 최상위 태그에 className 추가 * fix: useTabNavigationOnValidity에서 tab으로 접근 가능한 요소들의 목록 수정 --- frontend/src/components/common/CheckboxItem/index.tsx | 11 ++++++++--- .../slider/hooks/ally/useTabNavigationOnValidity.ts | 9 +++++++-- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/common/CheckboxItem/index.tsx b/frontend/src/components/common/CheckboxItem/index.tsx index f42ef24af..6157ff2ea 100644 --- a/frontend/src/components/common/CheckboxItem/index.tsx +++ b/frontend/src/components/common/CheckboxItem/index.tsx @@ -26,13 +26,18 @@ const CheckboxItem = ({ currentTarget: { id: id, checked: !isChecked, - } as Partial, - } as ChangeEvent); + } as Partial, + } as ChangeEvent); } }; return ( - + { if (event.code !== 'Tab') return; const currentCardElement = findCurrentCardElement(); - const lastTabCandidateList = currentCardElement?.querySelectorAll('input, textarea, button:not([disabled])'); + + // 리뷰 작성 카드에서, tab으로 접근 가능한 요소들은 + // 활성화된 버튼(이동 버튼, 프로그레스 바 버튼), CheckboxItem, textarea + const lastTabCandidateList = currentCardElement?.querySelectorAll( + 'textarea, .checkbox-item, button:not([disabled])', + ); if (!lastTabCandidateList || lastTabCandidateList.length === 0) return; const lastTabElementInCard = lastTabCandidateList[lastTabCandidateList.length - 1]; if (document.activeElement !== lastTabElementInCard) return; - //카드 속에서 마지막 탭 요소에 focus되어있고, tab키 누를 경우 + // 리뷰 작성 카드에서 tab 가능한 마지막 요소에 focus가 있을 때 tab키를 누를 경우 event.preventDefault(); (document.querySelector('footer a') as HTMLElement | null)?.focus(); }; From a92b85279d7265480ebeafbe4af44667585f72d0 Mon Sep 17 00:00:00 2001 From: sooyeon Date: Wed, 23 Oct 2024 14:57:40 +0900 Subject: [PATCH 49/49] =?UTF-8?q?[FE]=20feat:=20=EB=A9=94=ED=83=80=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=84=A4=EC=A0=95=20=EB=B0=8F=20?= =?UTF-8?q?OptionSwitch=20=EB=94=94=EC=9E=90=EC=9D=B8=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20(#915)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * design: 모바일 환경에서 스위치 버튼 width 100% 설정 및 Dropdown 오른쪽 정렬 * feat: 메타 데이터 이미지 추가 * feat: 메타 데이터 설정 --- frontend/public/index.html | 51 ++++++++++++------- frontend/src/assets/metaImage.svg | 10 ++++ .../components/common/OptionSwitch/styles.ts | 15 +++--- .../layouts/ReviewDisplayLayout/styles.ts | 2 +- .../ReviewCollectionPageContents/styles.ts | 4 -- 5 files changed, 52 insertions(+), 30 deletions(-) create mode 100644 frontend/src/assets/metaImage.svg diff --git a/frontend/public/index.html b/frontend/public/index.html index 789f3ec08..bc77378f2 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -1,22 +1,39 @@ + + + - - - - - - - - - - - REVIEW ME - + + + + + - -
- + + + + - \ No newline at end of file + + + REVIEW ME + + + +
+ + diff --git a/frontend/src/assets/metaImage.svg b/frontend/src/assets/metaImage.svg new file mode 100644 index 000000000..d9cfe8dc5 --- /dev/null +++ b/frontend/src/assets/metaImage.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/src/components/common/OptionSwitch/styles.ts b/frontend/src/components/common/OptionSwitch/styles.ts index fb7d0fddb..b2be3f531 100644 --- a/frontend/src/components/common/OptionSwitch/styles.ts +++ b/frontend/src/components/common/OptionSwitch/styles.ts @@ -1,7 +1,5 @@ import styled from '@emotion/styled'; -import media from '@/utils/media'; - import { OptionSwitchStyleProps } from './index'; export const OptionSwitchContainer = styled.ul` @@ -10,16 +8,17 @@ export const OptionSwitchContainer = styled.ul` display: flex; justify-content: space-between; - width: 15rem; - height: 4.5rem; + width: 20rem; + height: 4.4rem; padding: 0.7rem; background-color: ${({ theme }) => theme.colors.lightGray}; border-radius: ${({ theme }) => theme.borderRadius.basic}; - ${media.small} { - height: 3.5rem; - font-size: 1.2rem; + margin-top: 0.9rem; + + @media screen and (max-width: 530px) { + width: 100%; } `; @@ -43,6 +42,6 @@ export const CheckboxWrapper = styled.li` export const CheckboxButton = styled.button` user-select: none; - font-size: 1.2rem; + font-size: 1.4rem; color: ${({ $isChecked, theme }) => ($isChecked ? theme.colors.primary : theme.colors.black)}; `; diff --git a/frontend/src/components/layouts/ReviewDisplayLayout/styles.ts b/frontend/src/components/layouts/ReviewDisplayLayout/styles.ts index f75f20032..2f7058fe8 100644 --- a/frontend/src/components/layouts/ReviewDisplayLayout/styles.ts +++ b/frontend/src/components/layouts/ReviewDisplayLayout/styles.ts @@ -12,7 +12,7 @@ export const Container = styled.div` align-items: center; justify-content: space-between; - @media screen and (max-width: 500px) { + @media screen and (max-width: 530px) { flex-direction: column; align-items: flex-start; margin-bottom: 2.5rem; diff --git a/frontend/src/pages/ReviewCollectionPage/components/ReviewCollectionPageContents/styles.ts b/frontend/src/pages/ReviewCollectionPage/components/ReviewCollectionPageContents/styles.ts index 94566440e..6f6c9da44 100644 --- a/frontend/src/pages/ReviewCollectionPage/components/ReviewCollectionPageContents/styles.ts +++ b/frontend/src/pages/ReviewCollectionPage/components/ReviewCollectionPageContents/styles.ts @@ -15,10 +15,6 @@ export const ReviewCollectionContainer = styled.div` export const ReviewSectionDropdown = styled.div` display: flex; justify-content: flex-end; - - @media screen and (max-width: 500px) { - justify-content: flex-start; - } `; export const ReviewCollection = styled.div`