diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..06032ab2f --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,3 @@ +* @chysis @BadaHertz52 @soosoo22 @ImxYJL @donghoony @Kimprodp @nayonsoso @skylar1220 +/frontend/ @chysis @BadaHertz52 @soosoo22 @ImxYJL +/backend/ @donghoony @Kimprodp @nayonsoso @skylar1220 diff --git a/.github/workflows/backend-dev-cd.yml b/.github/workflows/backend-dev-cd.yml index d0d2c39b2..6a10d7c00 100644 --- a/.github/workflows/backend-dev-cd.yml +++ b/.github/workflows/backend-dev-cd.yml @@ -57,7 +57,7 @@ jobs: deploy: name: Deploy via self-hosted runner needs: build - runs-on: [self-hosted, dev] + runs-on: [self-hosted, dev, oracle] steps: - name: Checkout to secret repository @@ -81,7 +81,7 @@ jobs: env: PROFILE_VAR: "dev" run: | + sudo docker pull ${{ secrets.DOCKERHUB_ID }}/review-me-app:develop chmod +x ./deploy.sh sudo -E ./deploy.sh - working-directory: ${{ env.APPLICATION_DIRECTORY }}/app diff --git a/.github/workflows/backend-prod-cd.yml b/.github/workflows/backend-prod-cd.yml index c010d96e3..2033f023c 100644 --- a/.github/workflows/backend-prod-cd.yml +++ b/.github/workflows/backend-prod-cd.yml @@ -7,6 +7,17 @@ env: APPLICATION_DIRECTORY: /home/ubuntu/review-me jobs: + check-branch: + name: Check branch name + runs-on: ubuntu-latest + steps: + - name: Check branch name + if: ${{ github.ref_name != 'release' && !startsWith(github.ref_name, 'hotfix/') }} + run: | + echo "This workflow can only run on 'release' branch or branches starting with 'hotfix/'" + echo "Current branch: ${{ github.ref_name }}" + exit 1 + build: name: Build Dockerfile and push to DockerHub runs-on: ubuntu-latest @@ -52,10 +63,7 @@ jobs: deploy: name: Deploy via self-hosted runner needs: build - strategy: - matrix: - runner: [prod-a, prod-b] - runs-on: [ self-hosted, "${{ matrix.runner }}" ] + runs-on: [self-hosted, prod, oracle] steps: - name: Checkout to secret repository @@ -79,6 +87,7 @@ jobs: env: PROFILE_VAR: "prod" run: | + sudo docker pull ${{ secrets.DOCKERHUB_ID }}/review-me-app:release chmod +x ./deploy.sh sudo -E ./deploy.sh working-directory: ${{ env.APPLICATION_DIRECTORY }}/app diff --git a/.github/workflows/discord-pull-request-comment.yml b/.github/workflows/discord-pull-request-comment.yml index e1a3fc36b..d3594088f 100644 --- a/.github/workflows/discord-pull-request-comment.yml +++ b/.github/workflows/discord-pull-request-comment.yml @@ -31,7 +31,7 @@ jobs: elif [ "$PR_PREFIX" = '[FE]' ]; then echo Frontend PR Found! echo "PR_PREFIX=FE" >> $GITHUB_ENV - elif [ "$PR_PREFIX" = '[All]' ]; then + elif [ "$PR_PREFIX" = '[All]' ] || [ "$PR_PREFIX" = '[Release]' ]; then echo All PR Found! echo "PR_PREFIX=All" >> $GITHUB_ENV fi diff --git a/.github/workflows/discord-pull-request.yml b/.github/workflows/discord-pull-request.yml index 52b446ebb..9f9919b42 100644 --- a/.github/workflows/discord-pull-request.yml +++ b/.github/workflows/discord-pull-request.yml @@ -31,7 +31,7 @@ jobs: elif [ "$PR_PREFIX" = '[FE]' ]; then echo Frontend PR Found! echo "PR_PREFIX=FE" >> $GITHUB_ENV - elif [ "$PR_PREFIX" = '[All]' ]; then + elif [ "$PR_PREFIX" = '[All]' ] || [ "$PR_PREFIX" = '[Release]' ]; then echo All PR Found! echo "PR_PREFIX=All" >> $GITHUB_ENV fi diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml index 689733362..a64b9a52f 100644 --- a/.github/workflows/frontend-ci.yml +++ b/.github/workflows/frontend-ci.yml @@ -10,9 +10,9 @@ permissions: contents: read pages: write id-token: write -concurrency: +concurrency: group: "ci-group" - cancel-in-progress: false # NOTE: 기존 CI가 돌고 있는 상황에서 새 작업이 추가돼도 기존 작업 계속 수행 + cancel-in-progress: false # 기존 작업 계속 수행 jobs: build: runs-on: ubuntu-latest @@ -28,11 +28,31 @@ jobs: cache-dependency-path: ./frontend/yarn.lock - name: Create .env file - run: echo "API_BASE_URL=${{ secrets.API_BASE_URL }}" > ./frontend/.env + run: | + echo "API_BASE_URL=${{ secrets.API_BASE_URL }}" > ./frontend/.env + echo "SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}" >> ./frontend/.env + echo "AMPLITUDE_KEY=${{ secrets.AMPLITUDE_KEY }}" >> ./frontend/.env - name: Set environment file permissions run: chmod 644 ./frontend/.env + # 프리플라이트 체크 + - name: Preflight Check for Environment Variables + run: | + if [ -z "${{ secrets.API_BASE_URL }}" ]; then + echo "Error: API_BASE_URL is not set" + exit 1 + fi + if [ -z "${{ secrets.SENTRY_AUTH_TOKEN }}" ]; then + echo "Error: SENTRY_AUTH_TOKEN is not set" + exit 1 + fi + if [ -z "${{ secrets.AMPLITUDE_KEY }}" ]; then + echo "Error: AMPLITUDE_KEY is not set" + exit 1 + fi + shell: bash + - name: Install dependencies run: yarn install --frozen-lockfile working-directory: frontend @@ -45,4 +65,6 @@ jobs: run: yarn build env: API_BASE_URL: ${{ secrets.API_BASE_URL }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + AMPLITUDE_KEY: ${{ secrets.AMPLITUDE_KEY }} working-directory: frontend diff --git a/README.md b/README.md index c7e1b40ba..63ae0b310 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,65 @@ -# 리뷰미 +

리뷰미 로고

-> 🤔 우리 팀원은 나를 어떻게 생각할까? -> 🫂 나와 팀이 함께 성장하려면 어떻게 해야 할까? -> 🤨 팀원에게 하고 싶은 말이 있는데, 대면으로 하기가 민망하네.. -> 🥹 기능 구현 하기에도 바빠서 문화를 챙길 시간도 없고, 팀원들한테 이런거 하자고 하기도 부담스러워... +🔗[리뷰미 바로가기](https://review-me.page) -저희도 스스로가 팀에서 어떤 존재인지 고민될 때가 있습니다. +# 🔎 리뷰미 -동료의 피드백을 통해 저희는 자신의 강점과 팀에 어떻게 기여할 수 있는지를 알게 되었습니다. -지칠 때 받은 동료의 리뷰가 큰 힘이 되었어요. 팀원 모두가 서로를 응원하니 자연스럽게 팀워크도 향상됐습니다. -리뷰미는 동료로부터 기술뿐만 아니라 소프트 스킬, 나의 특징 등을 다방면으로 리뷰 받을 수 있는 서비스입니다. -리뷰미를 통해 협업하는 내 모습을 알아갈 수 있고, 나아가 함께 성장하는 방식을 고민할 수 있습니다. -어쩌면 내가 몰랐던 내 모습을 발견할 수도 있겠죠? +## 프로젝트 소개 +프로젝트를 함께한 동료들에게 받은 리뷰를 통해 자신이 어떤 개발자인지 파악하고 표현하는 데 도움을 주는 서비스입니다. +기술뿐만 아니라 소프트 스킬, 나의 강점 등을 다방면으로 리뷰 받을 수 있어요. +어쩌면 내가 몰랐던 내 모습을 발견할 수도 있겠죠? + +## 리뷰미가 세상에 나온 이유✨ +> 🤔 나는 무엇을 잘하는 개발자일까? +📚 어떤 점을 보완하면 내가 더 성장할 수 있을까? +🫂 우리 팀원은 나를 어떻게 생각할까? + +프로젝트를 하다보면 이런 고민이 들 때가 있지 않나요? +우리는 이 고민의 답을 `동료들의 피드백`에서 찾았어요. +동료들과 피드백을 주고받으며 `내가 팀에서 어떤 사람`이었고 `무엇을 잘하는지` 알 수 있었기 때문이에요. + +그렇게 동료들과 피드백을 주고받을 수 있는 서비스, `리뷰미`가 탄생하였습니다. + +## 주요 기능 소개 + +### 리뷰를 작성해보세요 +뭐라고 리뷰를 써야할지 막막한가요? 리뷰미를 통해 그 때의 기억을 떠올리며 리뷰를 작성해보세요. + +

+ +### 리뷰를 확인해보세요 +팀원들이 보는 내 모습은 어땠을까요? 작성한 리뷰를 확인해보세요! + +

+ +### 리뷰로 나를 파악해보세요 +받은 리뷰를 모아보고, 나를 파악하는데 도움이 된 부분을 형광펜으로 표시할 수 있어요. +

+ +## 😮 리뷰미 서비스 사용 후기 +

+ + +## ⚙️ 기술 스택 +### 프론트엔드 +

+ +### 백엔드 +

+ +### Infrastructure +

+ +## 🧑‍💻 팀원 소개 + +### 프론트엔드 +| bada | soosoo | fe | ollie | +| :---: | :---: | :---: | :---: | +| [🐋 바다](https://github.com/badahertz52) | [😍 쑤쑤](https://github.com/soosoo22) | [🔥 에프이](https://github.com/chysis) | [👾 올리](https://github.com/ImxYJL) | + +### 백엔드 +| | | | | +| :---: | :---: | :---: | :---: | +| [🦧 산초](https://github.com/nayonsoso) | [🤸🏻‍♂️ 아루](https://github.com/donghoony) | [💃 커비](https://github.com/skylar1220) | [🐻 테드](https://github.com/Kimprodp) | -여러분들도 리뷰를 통한 좋은 경험을 해보고 싶으시다면, -리뷰를 통해 누군가에게 응원을 전달하고 싶으시다면, -리뷰미와 함께하세요! diff --git a/backend/build.gradle b/backend/build.gradle index cea87d9cf..974a88f3e 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -29,27 +29,31 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-validation' - implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0' + implementation 'org.springframework.boot:spring-boot-starter-cache' implementation 'org.springframework.boot:spring-boot-starter-actuator' 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' + implementation 'com.h2database:h2' - runtimeOnly 'com.h2database:h2' runtimeOnly 'com.mysql:mysql-connector-j' - annotationProcessor 'org.projectlombok:lombok' + + // ConfigurationProperties annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' + // Test dependencies + testImplementation 'org.springframework.boot:spring-boot-starter-test' + + // Lombok + annotationProcessor 'org.projectlombok:lombok' testAnnotationProcessor 'org.projectlombok:lombok' testImplementation 'org.projectlombok:lombok' - testImplementation 'org.springframework.boot:spring-boot-starter-test' // RestDocs - asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor:3.0.1' - testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc:3.0.1' - testImplementation 'io.rest-assured:spring-mock-mvc:5.4.0' - testImplementation 'io.rest-assured:rest-assured:5.4.0' + asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor' + testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' + testImplementation 'io.rest-assured:spring-mock-mvc' + testImplementation 'io.rest-assured:rest-assured' } ext { diff --git a/backend/src/docs/asciidoc/auth.adoc b/backend/src/docs/asciidoc/auth.adoc new file mode 100644 index 000000000..17900fe72 --- /dev/null +++ b/backend/src/docs/asciidoc/auth.adoc @@ -0,0 +1,7 @@ +==== 깃허브로 로그인/회원가입 + +operation::github-auth[snippets="curl-request,request-fields,http-response"] + +==== 로그아웃 + +operation::logout[snippets="curl-request,request-cookies,http-response"] diff --git a/backend/src/docs/asciidoc/create-review.adoc b/backend/src/docs/asciidoc/create-review.adoc index 7b3464613..e05ef1b90 100644 --- a/backend/src/docs/asciidoc/create-review.adoc +++ b/backend/src/docs/asciidoc/create-review.adoc @@ -1,6 +1,10 @@ -==== 리뷰 생성 +==== 비회원이 리뷰 생성 -operation::create-review[snippets="curl-request,request-fields,http-response"] +operation::create-review-by-guest[snippets="curl-request,request-fields,http-response"] + +==== 회원이 리뷰 생성 + +operation::create-review-by-member[snippets="curl-request,request-fields,http-response"] ==== 그룹 코드가 올바르지 않은 경우 diff --git a/backend/src/docs/asciidoc/index.adoc b/backend/src/docs/asciidoc/index.adoc index 4d67754a7..0575b8a24 100644 --- a/backend/src/docs/asciidoc/index.adoc +++ b/backend/src/docs/asciidoc/index.adoc @@ -40,3 +40,11 @@ include::review-gather.adoc[] === 답변 하이라이트 include::highlight-answers.adoc[] + +== 인증 + +include::auth.adoc[] + +== 사용자 + +include::member.adoc[] \ No newline at end of file diff --git a/backend/src/docs/asciidoc/member.adoc b/backend/src/docs/asciidoc/member.adoc new file mode 100644 index 000000000..9a51e2c94 --- /dev/null +++ b/backend/src/docs/asciidoc/member.adoc @@ -0,0 +1,3 @@ +==== 내 프로필 정보 + +operation::my-profile[snippets="curl-request,request-cookies,http-response,response-fields"] \ No newline at end of file diff --git a/backend/src/docs/asciidoc/review-list.adoc b/backend/src/docs/asciidoc/review-list.adoc index 3d8648566..d0cf479ba 100644 --- a/backend/src/docs/asciidoc/review-list.adoc +++ b/backend/src/docs/asciidoc/review-list.adoc @@ -1,3 +1,7 @@ ==== 자신이 받은 리뷰 목록 조회 operation::received-review-list-with-pagination[snippets="curl-request,request-cookies,query-parameters,http-response,response-fields"] + +==== 자신이 작성한 리뷰 목록 조회 + +operation::authored-review-list-with-pagination[snippets="curl-request,query-parameters,http-response,response-fields"] diff --git a/backend/src/docs/asciidoc/reviewgroup.adoc b/backend/src/docs/asciidoc/reviewgroup.adoc index c3eb2f803..3b47c4228 100644 --- a/backend/src/docs/asciidoc/reviewgroup.adoc +++ b/backend/src/docs/asciidoc/reviewgroup.adoc @@ -1,11 +1,23 @@ -==== 리뷰 그룹 생성 +==== 비회원용 리뷰 그룹 생성 -operation::review-group-create[snippets="curl-request,request-fields,http-response,response-fields"] +operation::guest-review-group-create[snippets="curl-request,request-fields,http-response,response-fields"] -==== 리뷰 그룹 간단 정보 조회 +==== 회원용 리뷰 그룹 생성 -operation::review-group-summary[snippets="curl-request,http-response,response-fields"] +operation::member-review-group-create[snippets="curl-request,request-fields,http-response,response-fields"] + +==== 회원이 만든 리뷰 그룹 간단 정보 조회 + +operation::member-review-group-summary[snippets="curl-request,http-response,response-fields"] + +==== 비회원이 만든 리뷰 그룹 간단 정보 조회 + +operation::guest-review-group-summary[snippets="curl-request,http-response,response-fields"] ==== 리뷰 요청 코드, 확인 코드 일치 여부 operation::review-group-check-access[snippets="curl-request,request-fields,http-response,response-cookies"] + +==== 자신이 만든 리뷰 그룹 목록 조회 + +operation::review-group-list[snippets="curl-request,request-cookies,http-response,response-fields"] diff --git a/backend/src/main/java/reviewme/DatabaseInitializer.java b/backend/src/main/java/reviewme/DatabaseInitializer.java index 38d1e3496..aad6c16b8 100644 --- a/backend/src/main/java/reviewme/DatabaseInitializer.java +++ b/backend/src/main/java/reviewme/DatabaseInitializer.java @@ -5,18 +5,14 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; -import reviewme.question.domain.OptionGroup; -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; +import reviewme.template.domain.OptionGroup; +import reviewme.template.domain.OptionItem; +import reviewme.template.domain.OptionType; +import reviewme.template.domain.Question; +import reviewme.template.domain.QuestionType; import reviewme.template.domain.Section; import reviewme.template.domain.Template; import reviewme.template.domain.VisibleType; -import reviewme.template.repository.SectionRepository; import reviewme.template.repository.TemplateRepository; @Component @@ -28,12 +24,11 @@ public class DatabaseInitializer { private static final int KEYWORD_CHECKBOX_MIN_COUNT = 1; private static final int KEYWORD_CHECKBOX_MAX_COUNT = 2; - private final QuestionRepository questionRepository; - private final OptionItemRepository optionItemRepository; - private final OptionGroupRepository optionGroupRepository; - private final SectionRepository sectionRepository; private final TemplateRepository templateRepository; + // TODO: 하드코딩되어 있는 ID를 사용하지 않도록 한다. Factory 혹은 Builder를 활용해 Template 하나를 저장하도록 한다. + // TODO: 어드민 페이지를 활용해 Template을 관리하는 것이 추후 유지보수에 훨씬 이득일 수 있다. + @PostConstruct @Transactional public void setup() { @@ -43,104 +38,128 @@ public void setup() { } // 카테고리 선택 섹션 - long categoryQuestionId = questionRepository.save(new Question(true, QuestionType.CHECKBOX, "프로젝트 기간 동안, ${revieweeName}의 강점이 드러났던 순간을 선택해주세요.", null, 1)).getId(); - long categorySectionId = sectionRepository.save(new Section(VisibleType.ALWAYS, List.of(categoryQuestionId), null, "강점 발견", "${revieweeName/와:과} 함께 한 기억을 떠올려볼게요.", 1)).getId(); - long categoryOptionGroupId = optionGroupRepository.save(new OptionGroup(categoryQuestionId, KEYWORD_CHECKBOX_MIN_COUNT, KEYWORD_CHECKBOX_MAX_COUNT)).getId(); - long communicationOptionId = optionItemRepository.save(new OptionItem("🗣️커뮤니케이션, 협업 능력 (예: 팀원간의 원활한 정보 공유, 명확한 의사소통)", categoryOptionGroupId, 1, OptionType.CATEGORY)).getId(); - long problemSolvingOptionId = optionItemRepository.save(new OptionItem("💡문제 해결 능력 (예: 프로젝트 중 만난 버그/오류를 분석하고 이를 해결하는 능력)",categoryOptionGroupId,2, OptionType.CATEGORY )).getId(); - long timeManagingOptionId = optionItemRepository.save(new OptionItem("⏰시간 관리 능력 (예: 일정과 마감 기한 준수, 업무의 우선 순위 분배)",categoryOptionGroupId,3, OptionType.CATEGORY )).getId(); - long technicalOptionId = optionItemRepository.save(new OptionItem("💻기술적 역량, 전문 지식 (예: 요구 사항을 이해하고 이를 구현하는 능력)",categoryOptionGroupId,4, OptionType.CATEGORY )).getId(); - long growthOptionId = optionItemRepository.save(new OptionItem("🌱성장 마인드셋 (예: 새로운 분야나 잘 모르는 분야에 도전하는 마음, 꾸준한 노력으로 프로젝트 이전보다 성장하는 모습)",categoryOptionGroupId,5, OptionType.CATEGORY )).getId(); + OptionItem communicationOptionItem = new OptionItem("🗣️커뮤니케이션, 협업 능력 (예: 팀원간의 원활한 정보 공유, 명확한 의사소통)", 1, OptionType.CATEGORY); + OptionItem problemSolvingOptionItem = new OptionItem("💡문제 해결 능력 (예: 프로젝트 중 만난 버그/오류를 분석하고 이를 해결하는 능력)", 2, OptionType.CATEGORY); + OptionItem timeManagementOptionItem = new OptionItem("⏰시간 관리 능력 (예: 일정과 마감 기한 준수, 업무의 우선 순위 분배)", 3, OptionType.CATEGORY); + OptionItem technicalOptionItem = new OptionItem("💻기술적 역량, 전문 지식 (예: 요구 사항을 이해하고 이를 구현하는 능력)", 4, OptionType.CATEGORY); + OptionItem mindsetOptionItem = new OptionItem("🌱성장 마인드셋 (예: 새로운 분야나 잘 모르는 분야에 도전하는 마음, 꾸준한 노력으로 프로젝트 이전보다 성장하는 모습)", 5, OptionType.CATEGORY); + OptionGroup categorySectionOptionGroup = new OptionGroup( + List.of(communicationOptionItem, problemSolvingOptionItem, timeManagementOptionItem, technicalOptionItem, mindsetOptionItem), + KEYWORD_CHECKBOX_MIN_COUNT, + KEYWORD_CHECKBOX_MAX_COUNT + ); + Question categorySectionQuestion = new Question(true, QuestionType.CHECKBOX, categorySectionOptionGroup, "프로젝트 기간 동안, ${revieweeName}의 강점이 드러났던 순간을 선택해주세요.", null, 1); + Section categorySection = new Section(VisibleType.ALWAYS, List.of(categorySectionQuestion), null, "강점 발견", "${revieweeName/와:과} 함께 한 기억을 떠올려볼게요.", 1); // 커뮤니케이션 능력 섹션 - long checkBoxCommunicationQuestionId = questionRepository.save(new Question(true, QuestionType.CHECKBOX, "커뮤니케이션, 협업 능력에서 어떤 부분이 인상 깊었는지 선택해주세요.", null, 1)).getId(); - long textCommunicationQuestionId = questionRepository.save(new Question(true, QuestionType.TEXT, CATEGORY_TEXT_QUESTION, "상황을 자세하게 기록할수록 ${revieweeName}에게 도움이 돼요. ${revieweeName} 덕분에 팀이 원활한 소통을 이뤘거나, 함께 일하면서 배울 점이 있었는지 떠올려 보세요.", 2)).getId(); - long communicationSectionId = sectionRepository.save(new Section(VisibleType.CONDITIONAL, List.of(checkBoxCommunicationQuestionId, textCommunicationQuestionId), communicationOptionId, "커뮤니케이션 능력", CATEGORY_HEADER, 2)).getId(); - long communicationOptionGroupId = optionGroupRepository.save(new OptionGroup(checkBoxCommunicationQuestionId, KEYWORD_CHECKBOX_MIN_COUNT, KEYWORD_CHECKBOX_MAX_COUNT)).getId(); - optionItemRepository.save(new OptionItem("반대 의견을 내더라도 듣는 사람이 기분 나쁘지 않게 이야기해요.",communicationOptionGroupId,1, OptionType.KEYWORD )); - optionItemRepository.save(new OptionItem("팀원들의 의견을 잘 모아서 회의가 매끄럽게 진행되도록 해요.",communicationOptionGroupId,2, OptionType.KEYWORD )); - optionItemRepository.save(new OptionItem("팀의 분위기를 주도해요.",communicationOptionGroupId,3, OptionType.KEYWORD )); - optionItemRepository.save(new OptionItem("주장을 이야기할 때에는 합당한 근거가 뒤따라요.",communicationOptionGroupId,4, OptionType.KEYWORD )); - optionItemRepository.save(new OptionItem("팀에게 필요한 것과 그렇지 않은 것을 잘 구분해요.",communicationOptionGroupId,5, OptionType.KEYWORD )); - optionItemRepository.save(new OptionItem("팀 내 주어진 요구사항에 우선순위를 잘 매겨요.",communicationOptionGroupId,6, OptionType.KEYWORD )); - optionItemRepository.save(new OptionItem("서로 다른 분야간의 소통도 중요하게 생각해요.",communicationOptionGroupId,7, OptionType.KEYWORD )); + OptionGroup communicationOptionGroup = new OptionGroup( + List.of( + new OptionItem("반대 의견을 내더라도 듣는 사람이 기분 나쁘지 않게 이야기해요.", 1, OptionType.KEYWORD), + new OptionItem("팀원들의 의견을 잘 모아서 회의가 매끄럽게 진행되도록 해요.", 2, OptionType.KEYWORD), + new OptionItem("팀의 분위기를 주도해요.", 3, OptionType.KEYWORD), + new OptionItem("주장을 이야기할 때에는 합당한 근거가 뒤따라요.", 4, OptionType.KEYWORD), + new OptionItem("팀에게 필요한 것과 그렇지 않은 것을 잘 구분해요.", 5, OptionType.KEYWORD), + new OptionItem("팀 내 주어진 요구사항에 우선순위를 잘 매겨요.", 6, OptionType.KEYWORD), + new OptionItem("서로 다른 분야간의 소통도 중요하게 생각해요.", 7, OptionType.KEYWORD) + ), + KEYWORD_CHECKBOX_MIN_COUNT, + KEYWORD_CHECKBOX_MAX_COUNT + ); + Question communicationSectionQuestion = new Question(true, QuestionType.CHECKBOX, communicationOptionGroup, "커뮤니케이션, 협업 능력에서 어떤 부분이 인상 깊었는지 선택해주세요.", null, 1); + Question communicationSectionTextQuestion = new Question(true, QuestionType.TEXT, CATEGORY_TEXT_QUESTION, "상황을 자세하게 기록할수록 ${revieweeName}에게 도움이 돼요. ${revieweeName} 덕분에 팀이 원활한 소통을 이뤘거나, 함께 일하면서 배울 점이 있었는지 떠올려 보세요.", 2); + Section communicationSection = new Section(VisibleType.CONDITIONAL, List.of(communicationSectionQuestion, communicationSectionTextQuestion), communicationOptionItem, "커뮤니케이션 능력", CATEGORY_HEADER, 2); // 문제해결 능력 섹션 - long checkBoxProblemSolvingQuestionId = questionRepository.save(new Question(true, QuestionType.CHECKBOX, "문제해결 능력에서 어느 부분이 인상 깊었는지 선택해주세요.", null, 1)).getId(); - long textProblemSolvingQuestionId = questionRepository.save(new Question(true, QuestionType.TEXT, CATEGORY_TEXT_QUESTION, "상황을 자세하게 기록할수록 ${revieweeName}에게 도움이 돼요. 어떤 문제 상황이 발생했고, ${revieweeName/가:이} 어떻게 해결했는지 그 과정을 떠올려 보세요.", 2)).getId(); - long problemSolvingSectionId = sectionRepository.save(new Section(VisibleType.CONDITIONAL, List.of(checkBoxProblemSolvingQuestionId, textProblemSolvingQuestionId), problemSolvingOptionId, "문제해결 능력", CATEGORY_HEADER, 3)).getId(); - long problemSolvingOptionGroupId = optionGroupRepository.save(new OptionGroup(checkBoxProblemSolvingQuestionId, KEYWORD_CHECKBOX_MIN_COUNT, KEYWORD_CHECKBOX_MAX_COUNT)).getId(); - optionItemRepository.save(new OptionItem("큰 문제를 작은 단위로 쪼개서 단계별로 해결해나가요.",problemSolvingOptionGroupId,1, OptionType.KEYWORD )); - optionItemRepository.save(new OptionItem("낯선 문제를 만나도 당황하지 않고 차분하게 풀어나가요.",problemSolvingOptionGroupId,2, OptionType.KEYWORD )); - optionItemRepository.save(new OptionItem("문제 해결을 위해 GPT등의 자원을 적극적으로 활용해요.",problemSolvingOptionGroupId,3, OptionType.KEYWORD )); - optionItemRepository.save(new OptionItem("문제를 해결한 뒤에도 재발 방지를 위한 노력을 기울여요. (예: 문서화, 테스트 케이스 추가 등)",problemSolvingOptionGroupId,4, OptionType.KEYWORD )); - optionItemRepository.save(new OptionItem("문제의 원인을 적극적으로 탐구하고 해결해요. (예: 디버깅 툴의 적극적 활용 등)",problemSolvingOptionGroupId,5, OptionType.KEYWORD )); - optionItemRepository.save(new OptionItem("어려운 문제를 만나도 피하지 않고 도전해요.",problemSolvingOptionGroupId,6, OptionType.KEYWORD )); - optionItemRepository.save(new OptionItem("문제를 해결하기 위해 타인과 의사소통을 할 수 있어요. (예: 팀원과 이슈 공유, 문제 상황 설명 등)",problemSolvingOptionGroupId,7, OptionType.KEYWORD )); - optionItemRepository.save(new OptionItem("문제 원인과 해결책에 대한 가설을 세우고 직접 실험해봐요.",problemSolvingOptionGroupId,8, OptionType.KEYWORD )); + OptionGroup problemSolvingOptionGroup = new OptionGroup( + List.of( + new OptionItem("큰 문제를 작은 단위로 쪼개서 단계별로 해결해나가요.", 1, OptionType.KEYWORD), + new OptionItem("낯선 문제를 만나도 당황하지 않고 차분하게 풀어나가요.", 2, OptionType.KEYWORD), + new OptionItem("문제 해결을 위해 GPT등의 자원을 적극적으로 활용해요.", 3, OptionType.KEYWORD), + new OptionItem("문제를 해결한 뒤에도 재발 방지를 위한 노력을 기울여요. (예: 문서화, 테스트 케이스 추가 등)", 4, OptionType.KEYWORD), + new OptionItem("문제의 원인을 적극적으로 탐구하고 해결해요. (예: 디버깅 툴의 적극적 활용 등)", 5, OptionType.KEYWORD), + new OptionItem("어려운 문제를 만나도 피하지 않고 도전해요.", 6, OptionType.KEYWORD), + new OptionItem("문제를 해결하기 위해 타인과 의사소통을 할 수 있어요. (예: 팀원과 이슈 공유, 문제 상황 설명 등)", 7, OptionType.KEYWORD), + new OptionItem("문제 원인과 해결책에 대한 가설을 세우고 직접 실험해봐요.", 8, OptionType.KEYWORD) + ), + KEYWORD_CHECKBOX_MIN_COUNT, + KEYWORD_CHECKBOX_MAX_COUNT + ); + Question problemSolvingSectionQuestion = new Question(true, QuestionType.CHECKBOX, problemSolvingOptionGroup, "문제해결 능력에서 어느 부분이 인상 깊었는지 선택해주세요.", "상황을 자세하게 기록할수록 ${revieweeName}에게 도움이 돼요. ${revieweeName} 덕분에 팀이 어떤 문제 상황을 만났을 때, 어떻게 해결했는지 떠올려 보세요.", 1); + Question problemSolvingSectionTextQuestion = new Question(true, QuestionType.TEXT, CATEGORY_TEXT_QUESTION, "상황을 자세하게 기록할수록 ${revieweeName}에게 도움이 돼요. 어떤 문제 상황이 발생했고, ${revieweeName/가:이} 어떻게 해결했는지 그 과정을 떠올려 보세요.", 2); + Section problemSolvingSection = new Section(VisibleType.CONDITIONAL, List.of(problemSolvingSectionQuestion, problemSolvingSectionTextQuestion), problemSolvingOptionItem, "문제해결 능력", CATEGORY_HEADER, 3); // 시간 관리 능력 섹션 - long checkBoxTimeManagingQuestionId = questionRepository.save(new Question(true, QuestionType.CHECKBOX, "시간 관리 능력에서 어느 부분이 인상 깊었는지 선택해주세요.", null, 1)).getId(); - long textTimeManagingQuestionId = questionRepository.save(new Question(true, QuestionType.TEXT, CATEGORY_TEXT_QUESTION, "상황을 자세하게 기록할수록 ${revieweeName}에게 도움이 돼요. ${revieweeName} 덕분에 팀이 효율적으로 시간관리를 할 수 있었는지 떠올려 보세요.", 2)).getId(); - long timeManagingSectionId = sectionRepository.save(new Section(VisibleType.CONDITIONAL, List.of(checkBoxTimeManagingQuestionId, textTimeManagingQuestionId), timeManagingOptionId, "시간관리 능력", CATEGORY_HEADER, 4)).getId(); - long timeManagingOptionGroupId = optionGroupRepository.save(new OptionGroup(checkBoxTimeManagingQuestionId, KEYWORD_CHECKBOX_MIN_COUNT, KEYWORD_CHECKBOX_MAX_COUNT)).getId(); - optionItemRepository.save(new OptionItem("프로젝트의 일정과 주요 마일스톤을 설정하여 체계적으로 일정을 관리해요.",timeManagingOptionGroupId,1, OptionType.KEYWORD )); - optionItemRepository.save(new OptionItem("일정에 따라 마감 기한을 잘 지켜요.",timeManagingOptionGroupId,2, OptionType.KEYWORD )); - optionItemRepository.save(new OptionItem("업무의 중요도와 긴급성을 고려하여 우선 순위를 정하고, 그에 따라 작업을 분배해요.",timeManagingOptionGroupId,3, OptionType.KEYWORD )); - optionItemRepository.save(new OptionItem("예기치 않은 일정 변경에도 유연하게 대처해요.",timeManagingOptionGroupId,4, OptionType.KEYWORD )); - optionItemRepository.save(new OptionItem("회의 시간과 같은 약속된 시간을 잘 지켜요.",timeManagingOptionGroupId,5, OptionType.KEYWORD )); + OptionGroup timeManagingOptionGroup = new OptionGroup( + List.of( + new OptionItem("프로젝트의 일정과 주요 마일스톤을 설정하여 체계적으로 일정을 관리해요.", 1, OptionType.KEYWORD), + new OptionItem("일정에 따라 마감 기한을 잘 지켜요.", 2, OptionType.KEYWORD), + new OptionItem("업무의 중요도와 긴급성을 고려하여 우선 순위를 정하고, 그에 따라 작업을 분배해요.", 3, OptionType.KEYWORD), + new OptionItem("예기치 않은 일정 변경에도 유연하게 대처해요.", 4, OptionType.KEYWORD), + new OptionItem("회의 시간과 같은 약속된 시간을 잘 지켜요.", 5, OptionType.KEYWORD) + ), + KEYWORD_CHECKBOX_MIN_COUNT, + KEYWORD_CHECKBOX_MAX_COUNT + ); + Question timeManagingSectionQuestion = new Question(true, QuestionType.CHECKBOX, timeManagingOptionGroup, "시간 관리 능력에서 어느 부분이 인상 깊었는지 선택해주세요.", null, 1); + Question timeManagingSectionTextQuestion = new Question(true, QuestionType.TEXT, CATEGORY_TEXT_QUESTION, "상황을 자세하게 기록할수록 ${revieweeName}에게 도움이 돼요. ${revieweeName} 덕분에 팀이 효율적으로 시간관리를 할 수 있었는지 떠올려 보세요.", 2); + Section timeManagingSection = new Section(VisibleType.CONDITIONAL, List.of(timeManagingSectionQuestion, timeManagingSectionTextQuestion), timeManagementOptionItem, "시간 관리 능력", CATEGORY_HEADER, 4); // 기술 역량 섹션 - long checkBoxTechnicalQuestionId = questionRepository.save(new Question(true, QuestionType.CHECKBOX, "기술 역량, 전문 지식에서 어떤 부분이 인상 깊었는지 선택해주세요.", null, 1)).getId(); - long textTechnicalQuestionId = questionRepository.save(new Question(true, QuestionType.TEXT, CATEGORY_TEXT_QUESTION, "상황을 자세하게 기록할수록 ${revieweeName}에게 도움이 돼요. ${revieweeName} 덕분에 기술적 역량, 전문 지식적으로 도움을 받은 경험을 떠올려 보세요.", 2)).getId(); - long technicalSectionId = sectionRepository.save(new Section(VisibleType.CONDITIONAL, List.of(checkBoxTechnicalQuestionId, textTechnicalQuestionId), technicalOptionId, "기술 역량", CATEGORY_HEADER, 5)).getId(); - long technicalOptionGroupId = optionGroupRepository.save(new OptionGroup(checkBoxTechnicalQuestionId, KEYWORD_CHECKBOX_MIN_COUNT, KEYWORD_CHECKBOX_MAX_COUNT)).getId(); - optionItemRepository.save(new OptionItem("관련 언어 / 라이브러리 / 프레임워크 지식이 풍부해요.",technicalOptionGroupId,1, OptionType.KEYWORD )); - optionItemRepository.save(new OptionItem("인프라 지식이 풍부해요.",technicalOptionGroupId,2, OptionType.KEYWORD )); - optionItemRepository.save(new OptionItem("CS 지식이 풍부해요.",technicalOptionGroupId,3, OptionType.KEYWORD )); - optionItemRepository.save(new OptionItem("코드 리뷰에서 중요한 개선점을 제안했어요.",technicalOptionGroupId,4, OptionType.KEYWORD )); - optionItemRepository.save(new OptionItem("리팩토링을 통해 전체 코드의 품질을 향상시켰어요.",technicalOptionGroupId,5, OptionType.KEYWORD )); - optionItemRepository.save(new OptionItem("복잡한 버그를 신속하게 찾고 해결했어요.",technicalOptionGroupId,6, OptionType.KEYWORD )); - optionItemRepository.save(new OptionItem("꼼꼼하게 테스트를 작성했어요.",technicalOptionGroupId,7, OptionType.KEYWORD )); - optionItemRepository.save(new OptionItem("처음 보는 기술을 빠르게 습득하여 팀 프로젝트에 적용했어요.",technicalOptionGroupId,8, OptionType.KEYWORD )); - optionItemRepository.save(new OptionItem("명확하고 자세한 기술 문서를 작성하여 팀의 이해를 도왔어요.",technicalOptionGroupId,9, OptionType.KEYWORD )); - optionItemRepository.save(new OptionItem("컨벤션을 잘 지키면서 클린 코드를 작성하려고 노력했어요.",technicalOptionGroupId,10, OptionType.KEYWORD )); - optionItemRepository.save(new OptionItem("성능 최적화에 기여했어요.",technicalOptionGroupId,11, OptionType.KEYWORD )); - optionItemRepository.save(new OptionItem("지속적인 학습과 공유를 통해 팀의 기술 수준을 높였어요.",technicalOptionGroupId,12, OptionType.KEYWORD )); + OptionGroup technicalOptionGroup = new OptionGroup( + List.of( + new OptionItem("관련 언어 / 라이브러리 / 프레임워크 지식이 풍부해요.", 1, OptionType.KEYWORD), + new OptionItem("인프라 지식이 풍부해요.", 2, OptionType.KEYWORD), + new OptionItem("CS 지식이 풍부해요.", 3, OptionType.KEYWORD), + new OptionItem("코드 리뷰에서 중요한 개선점을 제안했어요.", 4, OptionType.KEYWORD), + new OptionItem("리팩토링을 통해 전체 코드의 품질을 향상시켰어요.", 5, OptionType.KEYWORD), + new OptionItem("복잡한 버그를 신속하게 찾고 해결했어요.", 6, OptionType.KEYWORD), + new OptionItem("꼼꼼하게 테스트를 작성했어요.", 7, OptionType.KEYWORD), + new OptionItem("처음 보는 기술을 빠르게 습득하여 팀 프로젝트에 적용했어요.", 8, OptionType.KEYWORD), + new OptionItem("명확하고 자세한 기술 문서를 작성하여 팀의 이해를 도왔어요.", 9, OptionType.KEYWORD), + new OptionItem("컨벤션을 잘 지키면서 클린 코드를 작성하려고 노력했어요.", 10, OptionType.KEYWORD), + new OptionItem("성능 최적화에 기여했어요.", 11, OptionType.KEYWORD), + new OptionItem("지속적인 학습과 공유를 통해 팀의 기술 수준을 높였어요.", 12, OptionType.KEYWORD) + ), + KEYWORD_CHECKBOX_MIN_COUNT, + KEYWORD_CHECKBOX_MAX_COUNT + ); + Question technicalSectionQuestion = new Question(true, QuestionType.CHECKBOX, technicalOptionGroup, "기술 역량, 전문 지식에서 어떤 부분이 인상 깊었는지 선택해주세요.", null, 1); + Question technicalSectionTextQuestion = new Question(true, QuestionType.TEXT, CATEGORY_TEXT_QUESTION, "상황을 자세하게 기록할수록 ${revieweeName}에게 도움이 돼요. ${revieweeName} 덕분에 기술적 역량, 전문 지식적으로 도움을 받은 경험을 떠올려 보세요.", 2); + Section technicalSection = new Section(VisibleType.CONDITIONAL, List.of(technicalSectionQuestion, technicalSectionTextQuestion), technicalOptionItem, "기술 역량, 전문 지식", CATEGORY_HEADER, 5); // 성장 마인드셋 섹션 - long checkBoxGrowthQuestionId = questionRepository.save(new Question(true, QuestionType.CHECKBOX, "성장 마인드셋에서 어떤 부분이 인상 깊었는지 선택해주세요.", null, 1)).getId(); - long textGrowthQuestionId = questionRepository.save(new Question(true, QuestionType.TEXT, CATEGORY_TEXT_QUESTION, "상황을 자세하게 기록할수록 ${revieweeName}에게 도움이 돼요. 인상깊었던 ${revieweeName}의 성장 마인드셋을 떠올려 보세요.", 2)).getId(); - long growthSectionId = sectionRepository.save(new Section(VisibleType.CONDITIONAL, List.of(checkBoxGrowthQuestionId, textGrowthQuestionId), growthOptionId, "성장 마인드셋", CATEGORY_HEADER, 6)).getId(); - long growthOptionGroupId = optionGroupRepository.save(new OptionGroup(checkBoxGrowthQuestionId, KEYWORD_CHECKBOX_MIN_COUNT, KEYWORD_CHECKBOX_MAX_COUNT)).getId(); - optionItemRepository.save(new OptionItem("어떤 상황에도 긍정적인 태도로 임해요.",growthOptionGroupId,1, OptionType.KEYWORD )); - optionItemRepository.save(new OptionItem("주변 사람들한테 질문하는 것을 부끄러워하지 않아요.",growthOptionGroupId,2, OptionType.KEYWORD )); - optionItemRepository.save(new OptionItem("어려움이 있어도 끝까지 해내요.",growthOptionGroupId,3, OptionType.KEYWORD )); - optionItemRepository.save(new OptionItem("함께 성장하기 위해, 배운 내용을 다른 사람과 공유해요.",growthOptionGroupId,4, OptionType.KEYWORD )); - optionItemRepository.save(new OptionItem("새로운 것을 두려워하지 않고 적극적으로 배워나가요.",growthOptionGroupId,5, OptionType.KEYWORD )); - optionItemRepository.save(new OptionItem("이론적 학습에서 그치지 않고 직접 적용하려 노력해요.",growthOptionGroupId,6, OptionType.KEYWORD )); - optionItemRepository.save(new OptionItem("다른 사람들과 비교하지 않고 본인만의 속도로 성장하는 법을 알고 있어요.",growthOptionGroupId,7, OptionType.KEYWORD )); - optionItemRepository.save(new OptionItem("받은 피드백을 빠르게 수용해요.",growthOptionGroupId,8, OptionType.KEYWORD )); - optionItemRepository.save(new OptionItem("회고를 통해 성장할 수 있는 방법을 스스로 탐색해요.",growthOptionGroupId,9, OptionType.KEYWORD )); - optionItemRepository.save(new OptionItem("새로운 아이디어를 시도하고, 기존의 틀을 깨는 것을 두려워하지 않아요.",growthOptionGroupId,10, OptionType.KEYWORD )); + OptionGroup mindsetOptionGroup = new OptionGroup( + List.of( + new OptionItem("어려운 상황에도 긍정적인 태도로 임했어요.", 1, OptionType.KEYWORD), + new OptionItem("주변 사람들한테 질문하는 것을 부끄러워하지 않았어요.", 2, OptionType.KEYWORD), + new OptionItem("어려움이 있어도 끝까지 해냈어요.", 3, OptionType.KEYWORD), + new OptionItem("함께 성장하기 위해, 배운 내용을 다른 사람과 공유했어요.", 4, OptionType.KEYWORD), + new OptionItem("새로운 것을 두려워하지 않고 적극적으로 배웠어요.", 5, OptionType.KEYWORD), + new OptionItem("이론적 학습에서 그치지 않고 직접 적용하려 노력했어요.", 6, OptionType.KEYWORD), + new OptionItem("다른 사람들과 비교하지 않고 본인만의 속도로 성장하는 법을 알고 있었어요.", 7, OptionType.KEYWORD), + new OptionItem("받은 피드백을 빠르게 수용했어요.", 8, OptionType.KEYWORD), + new OptionItem("회고를 통해 성장할 수 있는 방법을 스스로 탐색했어요.", 9, OptionType.KEYWORD), + new OptionItem("새로운 아이디어를 시도하고, 기존의 틀을 깨는 것을 두려워하지 않았어요.", 10, OptionType.KEYWORD) + ), + KEYWORD_CHECKBOX_MIN_COUNT, + KEYWORD_CHECKBOX_MAX_COUNT + ); + Question mindsetSectionQuestion = new Question(true, QuestionType.CHECKBOX, mindsetOptionGroup, "성장 마인드셋에서 어느 부분이 인상 깊었는지 선택해주세요.", null, 1); + Question mindsetSectionTextQuestion = new Question(true, QuestionType.TEXT, CATEGORY_TEXT_QUESTION, "상황을 자세하게 기록할수록 ${revieweeName}에게 도움이 돼요. 인상깊었던 ${revieweeName}의 성장 마인드셋을 떠올려 보세요.", 2); + Section mindsetSection = new Section(VisibleType.CONDITIONAL, List.of(mindsetSectionQuestion, mindsetSectionTextQuestion), mindsetOptionItem, "성장 마인드셋", CATEGORY_HEADER, 6); // 성장 목표 설정 섹션 - long textGrowthGoalQuestionId = questionRepository.save(new Question(true, QuestionType.TEXT, "앞으로의 성장을 위해서 ${revieweeName/가:이} 어떤 목표를 설정하면 좋을까요?", "어떤 점을 보완하면 좋을지와 함께 '이렇게 해보면 어떨까?'하는 간단한 솔루션을 제안해봐요.", 1)).getId(); - long textGrowthGoalSectionId = sectionRepository.save(new Section(VisibleType.ALWAYS, List.of(textGrowthGoalQuestionId), null, "보완할 점", "${revieweeName}의 성장을 도와주세요!", 7)).getId(); + Question growthTargetSectionQuestion = new Question(true, QuestionType.TEXT, "앞으로의 성장을 위해서 ${revieweeName/가:이} 어떤 목표를 설정하면 좋을까요?", "어떤 점을 보완하면 좋을지와 함께 '이렇게 해보면 어떨까?'하는 간단한 솔루션을 제안해봐요.", 1); + Section growthTargetSection = new Section(VisibleType.ALWAYS, List.of(growthTargetSectionQuestion), null, "보완할 점", "${revieweeName}의 성장을 도와주세요!", 7); // 응원의 말 섹션 - long textCheerUpQuestionId = questionRepository.save(new Question(false, QuestionType.TEXT, "${revieweeName}에게 전하고 싶은 다른 리뷰가 있거나 응원의 말이 있다면 적어주세요.", null, 1)).getId(); - long cheerUpSectionId = sectionRepository.save(new Section(VisibleType.ALWAYS, List.of(textCheerUpQuestionId), null, "추가 리뷰/응원", "리뷰를 더 하고 싶은 리뷰어를 위한 추가 리뷰!", 8)).getId(); + Question cheerUpSectionQuestion = new Question(false, QuestionType.TEXT, "${revieweeName}에게 전하고 싶은 다른 리뷰가 있거나 응원의 말이 있다면 적어주세요.", null, 1); + Section cheerUpSection = new Section(VisibleType.ALWAYS, List.of(cheerUpSectionQuestion), null, "추가 리뷰/응원", "리뷰를 더 하고 싶은 리뷰어를 위한 추가 리뷰!", 8); - templateRepository.save(new Template(List.of( - categorySectionId, - communicationSectionId, - problemSolvingSectionId, - timeManagingSectionId, - technicalSectionId, - growthSectionId, - textGrowthGoalSectionId, - cheerUpSectionId - ))); + Template template = new Template( + List.of(categorySection, communicationSection, problemSolvingSection, timeManagingSection, + technicalSection, mindsetSection, growthTargetSection, cheerUpSection) + ); + templateRepository.save(template); } } diff --git a/backend/src/main/java/reviewme/auth/controller/AuthController.java b/backend/src/main/java/reviewme/auth/controller/AuthController.java new file mode 100644 index 000000000..14573f2fe --- /dev/null +++ b/backend/src/main/java/reviewme/auth/controller/AuthController.java @@ -0,0 +1,33 @@ +package reviewme.auth.controller; + +import jakarta.servlet.http.HttpServletRequest; +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 reviewme.auth.service.AuthService; +import reviewme.auth.service.dto.GithubCodeRequest; + +@RestController +@RequiredArgsConstructor +public class AuthController { + + private final AuthService authService; + + @PostMapping("/v2/auth/github") + public ResponseEntity authWithGithub( + @Valid @RequestBody GithubCodeRequest request, + HttpServletRequest httpRequest + ) { + return ResponseEntity.ok().build(); + } + + @PostMapping("/v2/auth/logout") + public ResponseEntity logout( + HttpServletRequest httpRequest + ) { + return ResponseEntity.noContent().build(); + } +} diff --git a/backend/src/main/java/reviewme/auth/service/AuthService.java b/backend/src/main/java/reviewme/auth/service/AuthService.java new file mode 100644 index 000000000..5458807af --- /dev/null +++ b/backend/src/main/java/reviewme/auth/service/AuthService.java @@ -0,0 +1,7 @@ +package reviewme.auth.service; + +import org.springframework.stereotype.Service; + +@Service +public class AuthService { +} diff --git a/backend/src/main/java/reviewme/auth/service/dto/GithubCodeRequest.java b/backend/src/main/java/reviewme/auth/service/dto/GithubCodeRequest.java new file mode 100644 index 000000000..b26511917 --- /dev/null +++ b/backend/src/main/java/reviewme/auth/service/dto/GithubCodeRequest.java @@ -0,0 +1,8 @@ +package reviewme.auth.service.dto; + +import jakarta.validation.constraints.NotBlank; + +public record GithubCodeRequest( + @NotBlank(message = "깃허브 임시 코드를 입력해주세요.") + String code) { +} diff --git a/backend/src/main/java/reviewme/config/CacheManagerConfig.java b/backend/src/main/java/reviewme/config/CacheManagerConfig.java new file mode 100644 index 000000000..21151523f --- /dev/null +++ b/backend/src/main/java/reviewme/config/CacheManagerConfig.java @@ -0,0 +1,19 @@ +package reviewme.config; + +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.concurrent.ConcurrentMapCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; + +@Configuration +@EnableCaching +public class CacheManagerConfig { + + @Profile({"local", "dev", "prod"}) + @Bean + public CacheManager cacheManager() { + return new ConcurrentMapCacheManager(); + } +} diff --git a/backend/src/main/java/reviewme/config/RequestLimitProperties.java b/backend/src/main/java/reviewme/config/RequestLimitProperties.java deleted file mode 100644 index efea3b4f8..000000000 --- a/backend/src/main/java/reviewme/config/RequestLimitProperties.java +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index a8307db5f..000000000 --- a/backend/src/main/java/reviewme/config/RequestLimitRedisConfig.java +++ /dev/null @@ -1,34 +0,0 @@ -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 916ea5a41..d855040f0 100644 --- a/backend/src/main/java/reviewme/config/WebConfig.java +++ b/backend/src/main/java/reviewme/config/WebConfig.java @@ -3,11 +3,8 @@ 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; @@ -16,16 +13,9 @@ 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/config/CorsConfig.java b/backend/src/main/java/reviewme/config/cors/CorsConfig.java similarity index 90% rename from backend/src/main/java/reviewme/config/CorsConfig.java rename to backend/src/main/java/reviewme/config/cors/CorsConfig.java index 2f51720ea..ff1a483a3 100644 --- a/backend/src/main/java/reviewme/config/CorsConfig.java +++ b/backend/src/main/java/reviewme/config/cors/CorsConfig.java @@ -1,18 +1,16 @@ -package reviewme.config; +package reviewme.config.cors; import lombok.RequiredArgsConstructor; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import lombok.extern.slf4j.Slf4j; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +@Slf4j public class CorsConfig { - private static final Logger log = LoggerFactory.getLogger(CorsConfig.class); - private CorsConfig() { } diff --git a/backend/src/main/java/reviewme/config/CorsProperties.java b/backend/src/main/java/reviewme/config/cors/CorsProperties.java similarity index 91% rename from backend/src/main/java/reviewme/config/CorsProperties.java rename to backend/src/main/java/reviewme/config/cors/CorsProperties.java index 69a7d1c4d..e11a93667 100644 --- a/backend/src/main/java/reviewme/config/CorsProperties.java +++ b/backend/src/main/java/reviewme/config/cors/CorsProperties.java @@ -1,4 +1,4 @@ -package reviewme.config; +package reviewme.config.cors; import java.util.List; import org.springframework.boot.context.properties.ConfigurationProperties; diff --git a/backend/src/main/java/reviewme/config/DataSourceType.java b/backend/src/main/java/reviewme/config/datasource/DataSourceType.java similarity index 62% rename from backend/src/main/java/reviewme/config/DataSourceType.java rename to backend/src/main/java/reviewme/config/datasource/DataSourceType.java index c48080ab4..b40750df2 100644 --- a/backend/src/main/java/reviewme/config/DataSourceType.java +++ b/backend/src/main/java/reviewme/config/datasource/DataSourceType.java @@ -1,4 +1,4 @@ -package reviewme.config; +package reviewme.config.datasource; public enum DataSourceType { READ, diff --git a/backend/src/main/java/reviewme/config/ReplicationDatasourceConfig.java b/backend/src/main/java/reviewme/config/datasource/ReplicationDatasourceConfig.java similarity index 98% rename from backend/src/main/java/reviewme/config/ReplicationDatasourceConfig.java rename to backend/src/main/java/reviewme/config/datasource/ReplicationDatasourceConfig.java index 6a33a9e08..fb59b2498 100644 --- a/backend/src/main/java/reviewme/config/ReplicationDatasourceConfig.java +++ b/backend/src/main/java/reviewme/config/datasource/ReplicationDatasourceConfig.java @@ -1,4 +1,4 @@ -package reviewme.config; +package reviewme.config.datasource; import java.util.HashMap; import java.util.Map; @@ -54,4 +54,3 @@ public DataSource dataSource(@Qualifier(ROUTING_DATA_SOURCE_NAME) DataSource rou return new LazyConnectionDataSourceProxy(routingDataSource); } } - diff --git a/backend/src/main/java/reviewme/config/ReplicationRoutingDataSource.java b/backend/src/main/java/reviewme/config/datasource/ReplicationRoutingDataSource.java similarity index 93% rename from backend/src/main/java/reviewme/config/ReplicationRoutingDataSource.java rename to backend/src/main/java/reviewme/config/datasource/ReplicationRoutingDataSource.java index 49b7aa22b..f8a802467 100644 --- a/backend/src/main/java/reviewme/config/ReplicationRoutingDataSource.java +++ b/backend/src/main/java/reviewme/config/datasource/ReplicationRoutingDataSource.java @@ -1,4 +1,4 @@ -package reviewme.config; +package reviewme.config.datasource; import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource; import org.springframework.transaction.support.TransactionSynchronizationManager; diff --git a/backend/src/main/java/reviewme/global/GlobalExceptionHandler.java b/backend/src/main/java/reviewme/global/GlobalExceptionHandler.java index 9d4511618..7724dd90e 100644 --- a/backend/src/main/java/reviewme/global/GlobalExceptionHandler.java +++ b/backend/src/main/java/reviewme/global/GlobalExceptionHandler.java @@ -22,7 +22,6 @@ 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; @@ -51,11 +50,6 @@ 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 deleted file mode 100644 index b5747dfd1..000000000 --- a/backend/src/main/java/reviewme/global/RequestLimitInterceptor.java +++ /dev/null @@ -1,50 +0,0 @@ -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/FieldErrorResponse.java b/backend/src/main/java/reviewme/global/exception/FieldErrorResponse.java index e44edf619..ae0c678a4 100644 --- a/backend/src/main/java/reviewme/global/exception/FieldErrorResponse.java +++ b/backend/src/main/java/reviewme/global/exception/FieldErrorResponse.java @@ -1,8 +1,4 @@ package reviewme.global.exception; -public record FieldErrorResponse( - String field, - Object value, - String message -) { +public record FieldErrorResponse(String field, Object value, String message) { } diff --git a/backend/src/main/java/reviewme/global/exception/TooManyRequestException.java b/backend/src/main/java/reviewme/global/exception/TooManyRequestException.java deleted file mode 100644 index 4f26fee3e..000000000 --- a/backend/src/main/java/reviewme/global/exception/TooManyRequestException.java +++ /dev/null @@ -1,12 +0,0 @@ -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/java/reviewme/highlight/domain/HighlightedLines.java b/backend/src/main/java/reviewme/highlight/domain/HighlightedLines.java index f7000ecb2..f24827d30 100644 --- a/backend/src/main/java/reviewme/highlight/domain/HighlightedLines.java +++ b/backend/src/main/java/reviewme/highlight/domain/HighlightedLines.java @@ -2,6 +2,8 @@ import java.util.Arrays; import java.util.List; +import java.util.function.Function; +import java.util.stream.IntStream; import lombok.Getter; import reviewme.highlight.domain.exception.InvalidHighlightLineIndexException; import reviewme.highlight.domain.exception.NegativeHighlightLineIndexException; @@ -37,4 +39,12 @@ private void validateLineIndexRange(int lineIndex) { throw new InvalidHighlightLineIndexException(lineIndex, lines.size()); } } + + public List toHighlights(long answerId) { + return IntStream.range(0, lines.size()) + .mapToObj(lineIndex -> lines.get(lineIndex).getRanges().stream() + .map(range -> new Highlight(answerId, lineIndex, range))) + .flatMap(Function.identity()) + .toList(); + } } diff --git a/backend/src/main/java/reviewme/highlight/domain/exception/HighlightStartIndexExceedEndIndexException.java b/backend/src/main/java/reviewme/highlight/domain/exception/HighlightStartIndexExceedEndIndexException.java deleted file mode 100644 index 38c99ac9a..000000000 --- a/backend/src/main/java/reviewme/highlight/domain/exception/HighlightStartIndexExceedEndIndexException.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 HighlightStartIndexExceedEndIndexException extends BadRequestException { - - 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/repository/HighlightJdbcRepository.java b/backend/src/main/java/reviewme/highlight/repository/HighlightJdbcRepository.java new file mode 100644 index 000000000..b1e20ef36 --- /dev/null +++ b/backend/src/main/java/reviewme/highlight/repository/HighlightJdbcRepository.java @@ -0,0 +1,9 @@ +package reviewme.highlight.repository; + +import java.util.Collection; +import reviewme.highlight.domain.Highlight; + +public interface HighlightJdbcRepository { + + void saveAll(Collection highlights); +} diff --git a/backend/src/main/java/reviewme/highlight/repository/HighlightJdbcRepositoryImpl.java b/backend/src/main/java/reviewme/highlight/repository/HighlightJdbcRepositoryImpl.java new file mode 100644 index 000000000..077f6a531 --- /dev/null +++ b/backend/src/main/java/reviewme/highlight/repository/HighlightJdbcRepositoryImpl.java @@ -0,0 +1,24 @@ +package reviewme.highlight.repository; + +import java.util.Collection; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.jdbc.core.namedparam.SqlParameterSource; +import org.springframework.jdbc.core.namedparam.SqlParameterSourceUtils; +import reviewme.highlight.domain.Highlight; + +@RequiredArgsConstructor +public class HighlightJdbcRepositoryImpl implements HighlightJdbcRepository { + + private final NamedParameterJdbcTemplate namedParameterJdbcTemplate; + + @Override + public void saveAll(Collection highlights) { + SqlParameterSource[] parameterSources = SqlParameterSourceUtils.createBatch(highlights.toArray()); + String insertSql = """ + INSERT INTO highlight (answer_id, line_index, start_index, end_index) + VALUES (:answerId, :lineIndex, :highlightRange.startIndex, :highlightRange.endIndex) + """; + namedParameterJdbcTemplate.batchUpdate(insertSql, parameterSources); + } +} diff --git a/backend/src/main/java/reviewme/highlight/repository/HighlightRepository.java b/backend/src/main/java/reviewme/highlight/repository/HighlightRepository.java index 74760e09c..2733b2027 100644 --- a/backend/src/main/java/reviewme/highlight/repository/HighlightRepository.java +++ b/backend/src/main/java/reviewme/highlight/repository/HighlightRepository.java @@ -2,25 +2,32 @@ 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; +import org.springframework.data.repository.Repository; import reviewme.highlight.domain.Highlight; -public interface HighlightRepository extends JpaRepository { +public interface HighlightRepository extends Repository, HighlightJdbcRepository { - @Modifying - @Query(""" - DELETE FROM Highlight h - WHERE h.answerId IN :answerIds - """) - void deleteAllByAnswerIds(Collection answerIds); + Highlight save(Highlight highlight); + + boolean existsById(long id); @Query(""" - SELECT h - FROM Highlight h + SELECT h FROM Highlight h WHERE h.answerId IN :answerIds ORDER BY h.lineIndex, h.highlightRange.startIndex ASC """) List findAllByAnswerIdsOrderedAsc(Collection answerIds); + + @Modifying + @Query(""" + DELETE FROM Highlight h + WHERE h.answerId IN ( + SELECT a.id FROM Answer a + JOIN Review r ON a.reviewId = r.id + WHERE r.reviewGroupId = :reviewGroupId AND a.questionId = :questionId + ) + """) + void deleteByReviewGroupIdAndQuestionId(long reviewGroupId, long questionId); } diff --git a/backend/src/main/java/reviewme/highlight/service/HighlightService.java b/backend/src/main/java/reviewme/highlight/service/HighlightService.java index 7cb9f9c70..8651bca86 100644 --- a/backend/src/main/java/reviewme/highlight/service/HighlightService.java +++ b/backend/src/main/java/reviewme/highlight/service/HighlightService.java @@ -1,7 +1,6 @@ package reviewme.highlight.service; import java.util.List; -import java.util.Set; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -9,8 +8,7 @@ import reviewme.highlight.repository.HighlightRepository; import reviewme.highlight.service.dto.HighlightsRequest; import reviewme.highlight.service.mapper.HighlightMapper; -import reviewme.highlight.service.validator.HighlightValidator; -import reviewme.review.repository.AnswerRepository; +import reviewme.review.service.validator.AnswerValidator; import reviewme.reviewgroup.domain.ReviewGroup; @Service @@ -18,19 +16,18 @@ public class HighlightService { private final HighlightRepository highlightRepository; - private final AnswerRepository answerRepository; - private final HighlightValidator highlightValidator; private final HighlightMapper highlightMapper; + private final AnswerValidator answerValidator; @Transactional public void editHighlight(HighlightsRequest highlightsRequest, ReviewGroup reviewGroup) { - highlightValidator.validate(highlightsRequest, reviewGroup); - List highlights = highlightMapper.mapToHighlights(highlightsRequest); - - Set answerIds = answerRepository.findIdsByQuestionId(highlightsRequest.questionId()); - highlightRepository.deleteAllByAnswerIds(answerIds); + List requestedAnswerIds = highlightsRequest.getUniqueAnswerIds(); + answerValidator.validateQuestionContainsAnswers(highlightsRequest.questionId(), requestedAnswerIds); + answerValidator.validateReviewGroupContainsAnswers(reviewGroup, requestedAnswerIds); + List highlights = highlightMapper.mapToHighlights(highlightsRequest); + highlightRepository.deleteByReviewGroupIdAndQuestionId(reviewGroup.getId(), highlightsRequest.questionId()); highlightRepository.saveAll(highlights); } } diff --git a/backend/src/main/java/reviewme/highlight/service/dto/HighlightRequest.java b/backend/src/main/java/reviewme/highlight/service/dto/HighlightRequest.java index 673cc8e6a..1371c6959 100644 --- a/backend/src/main/java/reviewme/highlight/service/dto/HighlightRequest.java +++ b/backend/src/main/java/reviewme/highlight/service/dto/HighlightRequest.java @@ -4,6 +4,8 @@ import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; import java.util.List; +import java.util.stream.Stream; +import reviewme.highlight.service.mapper.HighlightFragment; public record HighlightRequest( @@ -13,4 +15,15 @@ public record HighlightRequest( @Valid @NotEmpty(message = "하이라이트 된 라인을 입력해주세요.") List lines ) { + public List toFragments() { + return lines.stream() + .flatMap(this::mapRangesToFragment) + .toList(); + } + + private Stream mapRangesToFragment(HighlightedLineRequest line) { + return line.ranges() + .stream() + .map(range -> new HighlightFragment(answerId, line.index(), range.startIndex(), range.endIndex())); + } } 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 b8f26cba6..b1f7f6de3 100644 --- a/backend/src/main/java/reviewme/highlight/service/dto/HighlightsRequest.java +++ b/backend/src/main/java/reviewme/highlight/service/dto/HighlightsRequest.java @@ -3,6 +3,7 @@ import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; import java.util.List; +import reviewme.highlight.service.mapper.HighlightFragment; public record HighlightsRequest( @@ -20,4 +21,10 @@ public List getUniqueAnswerIds() { .distinct() .toList(); } + + public List toFragments() { + return highlights.stream() + .flatMap(request -> request.toFragments().stream()) + .toList(); + } } diff --git a/backend/src/main/java/reviewme/highlight/service/exception/SubmittedAnswerAndProvidedAnswerMismatchException.java b/backend/src/main/java/reviewme/highlight/service/exception/SubmittedAnswerAndProvidedAnswerMismatchException.java deleted file mode 100644 index 0282bd983..000000000 --- a/backend/src/main/java/reviewme/highlight/service/exception/SubmittedAnswerAndProvidedAnswerMismatchException.java +++ /dev/null @@ -1,16 +0,0 @@ -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/mapper/HighlightFragment.java b/backend/src/main/java/reviewme/highlight/service/mapper/HighlightFragment.java new file mode 100644 index 000000000..33e08f56e --- /dev/null +++ b/backend/src/main/java/reviewme/highlight/service/mapper/HighlightFragment.java @@ -0,0 +1,4 @@ +package reviewme.highlight.service.mapper; + +public record HighlightFragment(long answerId, int lineIndex, int startIndex, int 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 index edbec9013..512546030 100644 --- a/backend/src/main/java/reviewme/highlight/service/mapper/HighlightMapper.java +++ b/backend/src/main/java/reviewme/highlight/service/mapper/HighlightMapper.java @@ -1,21 +1,14 @@ 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.domain.HighlightedLines; import reviewme.highlight.service.dto.HighlightsRequest; -import reviewme.review.domain.Answer; +import reviewme.review.domain.TextAnswer; import reviewme.review.repository.TextAnswerRepository; @Component @@ -25,53 +18,19 @@ public class HighlightMapper { private final TextAnswerRepository textAnswerRepository; public List mapToHighlights(HighlightsRequest highlightsRequest) { - Map answerHighlightLines = textAnswerRepository + Map answerIdHighlightedLines = 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; - } + .collect(Collectors.toMap(TextAnswer::getId, answer -> new HighlightedLines(answer.getContent()))); - 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); + for (HighlightFragment fragment : highlightsRequest.toFragments()) { + HighlightedLines highlightedLines = answerIdHighlightedLines.get(fragment.answerId()); + highlightedLines.addRange(fragment.lineIndex(), fragment.startIndex(), fragment.endIndex()); } - } - 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); - } + return answerIdHighlightedLines.entrySet() + .stream() + .flatMap(entry -> entry.getValue().toHighlights(entry.getKey()).stream()) + .toList(); } } diff --git a/backend/src/main/java/reviewme/highlight/service/validator/HighlightValidator.java b/backend/src/main/java/reviewme/highlight/service/validator/HighlightValidator.java deleted file mode 100644 index e05f0f9df..000000000 --- a/backend/src/main/java/reviewme/highlight/service/validator/HighlightValidator.java +++ /dev/null @@ -1,41 +0,0 @@ - -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.HighlightsRequest; -import reviewme.highlight.service.exception.SubmittedAnswerAndProvidedAnswerMismatchException; -import reviewme.review.repository.AnswerRepository; -import reviewme.reviewgroup.domain.ReviewGroup; - -@Component -@RequiredArgsConstructor -public class HighlightValidator { - - private final AnswerRepository answerRepository; - - public void validate(HighlightsRequest request, ReviewGroup reviewGroup) { - validateQuestionContainsAnswer(request); - validateReviewGroupContainsAnswer(request, reviewGroup); - } - - private void validateQuestionContainsAnswer(HighlightsRequest request) { - Set providedAnswerIds = answerRepository.findIdsByQuestionId(request.questionId()); - List submittedAnswerIds = request.getUniqueAnswerIds(); - - if (!providedAnswerIds.containsAll(submittedAnswerIds)) { - throw new SubmittedAnswerAndProvidedAnswerMismatchException(providedAnswerIds, submittedAnswerIds); - } - } - - private void validateReviewGroupContainsAnswer(HighlightsRequest request, ReviewGroup reviewGroup) { - Set providedAnswerIds = answerRepository.findIdsByReviewGroupId(reviewGroup.getId()); - List submittedAnswerIds = request.getUniqueAnswerIds(); - - if (!providedAnswerIds.containsAll(submittedAnswerIds)) { - throw new SubmittedAnswerAndProvidedAnswerMismatchException(providedAnswerIds, submittedAnswerIds); - } - } -} diff --git a/backend/src/main/java/reviewme/member/controller/MemberController.java b/backend/src/main/java/reviewme/member/controller/MemberController.java new file mode 100644 index 000000000..662ccf88c --- /dev/null +++ b/backend/src/main/java/reviewme/member/controller/MemberController.java @@ -0,0 +1,21 @@ +package reviewme.member.controller; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import reviewme.member.service.MemberService; +import reviewme.member.service.dto.ProfileResponse; + +@RestController +@RequiredArgsConstructor +public class MemberController { + + private final MemberService memberService; + + @GetMapping("/v2/members/profile") + public ResponseEntity getProfile() { + ProfileResponse response = memberService.getProfile(); + return ResponseEntity.ok(response); + } +} diff --git a/backend/src/main/java/reviewme/member/service/MemberService.java b/backend/src/main/java/reviewme/member/service/MemberService.java new file mode 100644 index 000000000..ef828b848 --- /dev/null +++ b/backend/src/main/java/reviewme/member/service/MemberService.java @@ -0,0 +1,12 @@ +package reviewme.member.service; + +import org.springframework.stereotype.Service; +import reviewme.member.service.dto.ProfileResponse; + +@Service +public class MemberService { + + public ProfileResponse getProfile() { + return null; + } +} diff --git a/backend/src/main/java/reviewme/member/service/dto/ProfileResponse.java b/backend/src/main/java/reviewme/member/service/dto/ProfileResponse.java new file mode 100644 index 000000000..5ec6900cd --- /dev/null +++ b/backend/src/main/java/reviewme/member/service/dto/ProfileResponse.java @@ -0,0 +1,7 @@ +package reviewme.member.service.dto; + +public record ProfileResponse( + String nickname, + String profileImageUrl +) { +} diff --git a/backend/src/main/java/reviewme/question/domain/OptionGroup.java b/backend/src/main/java/reviewme/question/domain/OptionGroup.java deleted file mode 100644 index 61aa3d23a..000000000 --- a/backend/src/main/java/reviewme/question/domain/OptionGroup.java +++ /dev/null @@ -1,39 +0,0 @@ -package reviewme.question.domain; - -import jakarta.persistence.Column; -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 = "option_group") -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@EqualsAndHashCode(of = "id") -@Getter -public class OptionGroup { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(name = "question_id", nullable = false) - private long questionId; - - @Column(name = "min_selection_count", nullable = false) - private int minSelectionCount; - - @Column(name = "max_selection_count", nullable = false) - private int maxSelectionCount; - - public OptionGroup(long questionId, int minSelectionCount, int maxSelectionCount) { - this.questionId = questionId; - this.minSelectionCount = minSelectionCount; - this.maxSelectionCount = maxSelectionCount; - } -} diff --git a/backend/src/main/java/reviewme/question/repository/QuestionRepository.java b/backend/src/main/java/reviewme/question/repository/QuestionRepository.java deleted file mode 100644 index 9db137d25..000000000 --- a/backend/src/main/java/reviewme/question/repository/QuestionRepository.java +++ /dev/null @@ -1,49 +0,0 @@ -package reviewme.question.repository; - -import java.util.List; -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(""" - 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(""" - 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(""" - 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 1b31af214..0689e6fa0 100644 --- a/backend/src/main/java/reviewme/review/controller/ReviewController.java +++ b/backend/src/main/java/reviewme/review/controller/ReviewController.java @@ -18,8 +18,9 @@ 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.ReceivedReviewsResponse; +import reviewme.review.service.dto.response.list.ReceivedReviewPageResponse; import reviewme.review.service.dto.response.list.ReceivedReviewsSummaryResponse; +import reviewme.review.service.dto.response.list.AuthoredReviewsResponse; import reviewme.reviewgroup.controller.ReviewGroupSession; import reviewme.reviewgroup.domain.ReviewGroup; @@ -35,17 +36,18 @@ public class ReviewController { @PostMapping("/v2/reviews") public ResponseEntity createReview(@Valid @RequestBody ReviewRegisterRequest request) { + // 회원 세션 추후 추가해야 함 long savedReviewId = reviewRegisterService.registerReview(request); return ResponseEntity.created(URI.create("/reviews/" + savedReviewId)).build(); } - @GetMapping("/v2/reviews") - public ResponseEntity findReceivedReviews( + @GetMapping("/v2/reviews/received") + public ResponseEntity findReceivedReviews( @RequestParam(required = false) Long lastReviewId, @RequestParam(required = false) Integer size, @ReviewGroupSession ReviewGroup reviewGroup ) { - ReceivedReviewsResponse response = reviewListLookupService.getReceivedReviews(lastReviewId, size, reviewGroup); + ReceivedReviewPageResponse response = reviewListLookupService.getReceivedReviews(lastReviewId, size, reviewGroup); return ResponseEntity.ok(response); } @@ -75,4 +77,15 @@ public ResponseEntity getReceivedReviewsBySect reviewGatheredLookupService.getReceivedReviewsBySectionId(reviewGroup, sectionId); return ResponseEntity.ok(response); } + + @GetMapping("/v2/reviews/authored") + public ResponseEntity findAuthoredReviews( + @RequestParam(required = false) Long lastReviewId, + @RequestParam(required = false) Integer size +// @MemberSession Member member + // TODO: 세션을 활용한 권한 체계에 따른 추가 조치 필요 + ) { + AuthoredReviewsResponse response = reviewListLookupService.getAuthoredReviews(lastReviewId, size); + return ResponseEntity.ok(response); + } } diff --git a/backend/src/main/java/reviewme/review/domain/exception/MissingTextAnswerForQuestionException.java b/backend/src/main/java/reviewme/review/domain/exception/MissingTextAnswerForQuestionException.java deleted file mode 100644 index 674dce41c..000000000 --- a/backend/src/main/java/reviewme/review/domain/exception/MissingTextAnswerForQuestionException.java +++ /dev/null @@ -1,13 +0,0 @@ -package reviewme.review.domain.exception; - -import lombok.extern.slf4j.Slf4j; -import reviewme.global.exception.DataInconsistencyException; - -@Slf4j -public class MissingTextAnswerForQuestionException extends DataInconsistencyException { - - public MissingTextAnswerForQuestionException(long questionId) { - super("서버 내부에 문제가 발생했습니다. 잠시 후 다시 시도해주세요."); - log.error("The question is a text question but text answer not found for questionId: {}", questionId, this); - } -} diff --git a/backend/src/main/java/reviewme/review/repository/AnswerRepository.java b/backend/src/main/java/reviewme/review/repository/AnswerRepository.java index ea793623a..5b18ab9f2 100644 --- a/backend/src/main/java/reviewme/review/repository/AnswerRepository.java +++ b/backend/src/main/java/reviewme/review/repository/AnswerRepository.java @@ -15,7 +15,7 @@ public interface AnswerRepository extends JpaRepository { 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 + ORDER BY r.createdAt DESC, r.id DESC LIMIT :limit """) List findReceivedAnswersByQuestionIds(long reviewGroupId, Collection questionIds, int limit); diff --git a/backend/src/main/java/reviewme/review/repository/ReviewRepository.java b/backend/src/main/java/reviewme/review/repository/ReviewRepository.java index 3a0600ad9..90119fa0b 100644 --- a/backend/src/main/java/reviewme/review/repository/ReviewRepository.java +++ b/backend/src/main/java/reviewme/review/repository/ReviewRepository.java @@ -12,7 +12,7 @@ public interface ReviewRepository extends JpaRepository { @Query(""" SELECT r FROM Review r WHERE r.reviewGroupId = :reviewGroupId - ORDER BY r.createdAt DESC + ORDER BY r.createdAt DESC, r.id DESC """) List findAllByGroupId(long reviewGroupId); diff --git a/backend/src/main/java/reviewme/review/service/ReviewGatheredLookupService.java b/backend/src/main/java/reviewme/review/service/ReviewGatheredLookupService.java index 703348c9e..82de0ab16 100644 --- a/backend/src/main/java/reviewme/review/service/ReviewGatheredLookupService.java +++ b/backend/src/main/java/reviewme/review/service/ReviewGatheredLookupService.java @@ -9,8 +9,8 @@ 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.template.domain.Question; +import reviewme.template.repository.QuestionRepository; import reviewme.review.domain.Answer; import reviewme.review.repository.AnswerRepository; import reviewme.review.service.dto.response.gathered.ReviewsGatheredBySectionResponse; diff --git a/backend/src/main/java/reviewme/review/service/ReviewListLookupService.java b/backend/src/main/java/reviewme/review/service/ReviewListLookupService.java index d576e9eeb..933e5a219 100644 --- a/backend/src/main/java/reviewme/review/service/ReviewListLookupService.java +++ b/backend/src/main/java/reviewme/review/service/ReviewListLookupService.java @@ -5,8 +5,9 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; 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.dto.response.list.AuthoredReviewsResponse; +import reviewme.review.service.dto.response.list.ReceivedReviewPageResponse; +import reviewme.review.service.dto.response.list.ReceivedReviewPageElementResponse; import reviewme.review.service.mapper.ReviewListMapper; import reviewme.reviewgroup.domain.ReviewGroup; @@ -18,30 +19,35 @@ public class ReviewListLookupService { private final ReviewListMapper reviewListMapper; @Transactional(readOnly = true) - public ReceivedReviewsResponse getReceivedReviews(Long lastReviewId, Integer size, ReviewGroup reviewGroup) { + public ReceivedReviewPageResponse getReceivedReviews(Long lastReviewId, Integer size, ReviewGroup reviewGroup) { PageSize pageSize = new PageSize(size); - List reviewListResponse + List reviewListResponse = reviewListMapper.mapToReviewList(reviewGroup, lastReviewId, pageSize.getSize()); long newLastReviewId = calculateLastReviewId(reviewListResponse); boolean isLastPage = isLastPage(reviewListResponse, reviewGroup); - return new ReceivedReviewsResponse( + return new ReceivedReviewPageResponse( reviewGroup.getReviewee(), reviewGroup.getProjectName(), newLastReviewId, isLastPage, reviewListResponse ); } - private long calculateLastReviewId(List elements) { + public AuthoredReviewsResponse getAuthoredReviews(Long lastReviewId, Integer size) { + // TODO: 생성일자 최신순 정렬 + return null; + } + + private long calculateLastReviewId(List elements) { if (elements.isEmpty()) { return 0; } return elements.get(elements.size() - 1).reviewId(); } - private boolean isLastPage(List elements, ReviewGroup reviewGroup) { + private boolean isLastPage(List elements, ReviewGroup reviewGroup) { if (elements.isEmpty()) { return true; } - ReviewListElementResponse lastReviewResponse = elements.get(elements.size() - 1); + ReceivedReviewPageElementResponse lastReviewResponse = elements.get(elements.size() - 1); return !reviewRepository.existsOlderReviewInGroup( reviewGroup.getId(), lastReviewResponse.reviewId(), lastReviewResponse.createdAt()); } diff --git a/backend/src/main/java/reviewme/review/service/ReviewRegisterService.java b/backend/src/main/java/reviewme/review/service/ReviewRegisterService.java index 966eaa602..87e5f8538 100644 --- a/backend/src/main/java/reviewme/review/service/ReviewRegisterService.java +++ b/backend/src/main/java/reviewme/review/service/ReviewRegisterService.java @@ -1,8 +1,6 @@ package reviewme.review.service; import lombok.RequiredArgsConstructor; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import reviewme.review.domain.Review; @@ -15,8 +13,6 @@ @RequiredArgsConstructor public class ReviewRegisterService { - private static final Logger log = LoggerFactory.getLogger(ReviewRegisterService.class); - private final ReviewMapper reviewMapper; private final ReviewValidator reviewValidator; private final ReviewRepository reviewRepository; diff --git a/backend/src/main/java/reviewme/review/service/dto/request/ReviewAnswerRequest.java b/backend/src/main/java/reviewme/review/service/dto/request/ReviewAnswerRequest.java index 60233be2d..85d89cd7e 100644 --- a/backend/src/main/java/reviewme/review/service/dto/request/ReviewAnswerRequest.java +++ b/backend/src/main/java/reviewme/review/service/dto/request/ReviewAnswerRequest.java @@ -15,11 +15,12 @@ public record ReviewAnswerRequest( @Nullable String text ) { - public boolean hasTextAnswer() { - return text != null && !text.isEmpty(); + + public boolean hasNoText() { + return text == null || text.isBlank(); } - public boolean hasCheckboxAnswer() { - return selectedOptionIds != null && !selectedOptionIds.isEmpty(); + public boolean hasNoSelectedOptions() { + return selectedOptionIds == null || selectedOptionIds.isEmpty(); } } diff --git a/backend/src/main/java/reviewme/review/service/dto/response/detail/OptionGroupAnswerResponse.java b/backend/src/main/java/reviewme/review/service/dto/response/detail/OptionGroupAnswerResponse.java deleted file mode 100644 index 894dbaae8..000000000 --- a/backend/src/main/java/reviewme/review/service/dto/response/detail/OptionGroupAnswerResponse.java +++ /dev/null @@ -1,11 +0,0 @@ -package reviewme.review.service.dto.response.detail; - -import java.util.List; - -public record OptionGroupAnswerResponse( - long optionGroupId, - long minCount, - long maxCount, - List options -) { -} diff --git a/backend/src/main/java/reviewme/review/service/dto/response/detail/OptionItemAnswerResponse.java b/backend/src/main/java/reviewme/review/service/dto/response/detail/OptionItemAnswerResponse.java index 6bd424f5f..d5c9ae174 100644 --- a/backend/src/main/java/reviewme/review/service/dto/response/detail/OptionItemAnswerResponse.java +++ b/backend/src/main/java/reviewme/review/service/dto/response/detail/OptionItemAnswerResponse.java @@ -2,7 +2,6 @@ public record OptionItemAnswerResponse( long optionId, - String content, - boolean isChecked + String content ) { } diff --git a/backend/src/main/java/reviewme/review/service/dto/response/detail/QuestionAnswerResponse.java b/backend/src/main/java/reviewme/review/service/dto/response/detail/QuestionAnswerResponse.java index 000eb83c8..761a86f76 100644 --- a/backend/src/main/java/reviewme/review/service/dto/response/detail/QuestionAnswerResponse.java +++ b/backend/src/main/java/reviewme/review/service/dto/response/detail/QuestionAnswerResponse.java @@ -1,14 +1,15 @@ package reviewme.review.service.dto.response.detail; import jakarta.annotation.Nullable; -import reviewme.question.domain.QuestionType; +import java.util.List; +import reviewme.template.domain.QuestionType; public record QuestionAnswerResponse( long questionId, boolean required, QuestionType questionType, - String content, - @Nullable OptionGroupAnswerResponse optionGroup, + String questionContents, + @Nullable List options, @Nullable String answer ) { } diff --git a/backend/src/main/java/reviewme/review/service/dto/response/detail/SectionAnswerResponse.java b/backend/src/main/java/reviewme/review/service/dto/response/detail/SectionAnswerResponse.java index eb45bddb2..ec330b76f 100644 --- a/backend/src/main/java/reviewme/review/service/dto/response/detail/SectionAnswerResponse.java +++ b/backend/src/main/java/reviewme/review/service/dto/response/detail/SectionAnswerResponse.java @@ -5,10 +5,6 @@ public record SectionAnswerResponse( long sectionId, String header, - List questions + List reviews ) { - - public boolean hasAnsweredQuestion() { - return !questions.isEmpty(); - } } 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 e16df25e6..d7f1647d9 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 @@ -1,6 +1,6 @@ package reviewme.review.service.dto.response.gathered; -import reviewme.question.domain.QuestionType; +import reviewme.template.domain.QuestionType; public record SimpleQuestionResponse( long id, diff --git a/backend/src/main/java/reviewme/review/service/dto/response/list/AuthoredReviewElementResponse.java b/backend/src/main/java/reviewme/review/service/dto/response/list/AuthoredReviewElementResponse.java new file mode 100644 index 000000000..282f6dbd3 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/dto/response/list/AuthoredReviewElementResponse.java @@ -0,0 +1,14 @@ +package reviewme.review.service.dto.response.list; + +import java.time.LocalDate; +import java.util.List; + +public record AuthoredReviewElementResponse( + long reviewId, + String revieweeName, + String projectName, + LocalDate createdAt, + String contentPreview, + List categories +) { +} diff --git a/backend/src/main/java/reviewme/review/service/dto/response/list/AuthoredReviewsResponse.java b/backend/src/main/java/reviewme/review/service/dto/response/list/AuthoredReviewsResponse.java new file mode 100644 index 000000000..7d712a31c --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/dto/response/list/AuthoredReviewsResponse.java @@ -0,0 +1,10 @@ +package reviewme.review.service.dto.response.list; + +import java.util.List; + +public record AuthoredReviewsResponse( + List reviews, + long lastReviewId, + boolean isLastPage +) { +} diff --git a/backend/src/main/java/reviewme/review/service/dto/response/list/ReviewListElementResponse.java b/backend/src/main/java/reviewme/review/service/dto/response/list/ReceivedReviewPageElementResponse.java similarity index 83% rename from backend/src/main/java/reviewme/review/service/dto/response/list/ReviewListElementResponse.java rename to backend/src/main/java/reviewme/review/service/dto/response/list/ReceivedReviewPageElementResponse.java index 07aa32c9f..5aa7c36e5 100644 --- a/backend/src/main/java/reviewme/review/service/dto/response/list/ReviewListElementResponse.java +++ b/backend/src/main/java/reviewme/review/service/dto/response/list/ReceivedReviewPageElementResponse.java @@ -3,7 +3,7 @@ import java.time.LocalDate; import java.util.List; -public record ReviewListElementResponse( +public record ReceivedReviewPageElementResponse( long reviewId, LocalDate createdAt, String contentPreview, diff --git a/backend/src/main/java/reviewme/review/service/dto/response/list/ReceivedReviewsResponse.java b/backend/src/main/java/reviewme/review/service/dto/response/list/ReceivedReviewPageResponse.java similarity index 66% rename from backend/src/main/java/reviewme/review/service/dto/response/list/ReceivedReviewsResponse.java rename to backend/src/main/java/reviewme/review/service/dto/response/list/ReceivedReviewPageResponse.java index eace5cd50..35042e0d5 100644 --- a/backend/src/main/java/reviewme/review/service/dto/response/list/ReceivedReviewsResponse.java +++ b/backend/src/main/java/reviewme/review/service/dto/response/list/ReceivedReviewPageResponse.java @@ -2,11 +2,11 @@ import java.util.List; -public record ReceivedReviewsResponse( +public record ReceivedReviewPageResponse( String revieweeName, String projectName, long lastReviewId, boolean isLastPage, - List reviews + List reviews ) { } diff --git a/backend/src/main/java/reviewme/review/service/exception/AnswerNotFoundByIdException.java b/backend/src/main/java/reviewme/review/service/exception/AnswerNotFoundByIdException.java deleted file mode 100644 index aef381ffc..000000000 --- a/backend/src/main/java/reviewme/review/service/exception/AnswerNotFoundByIdException.java +++ /dev/null @@ -1,13 +0,0 @@ -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/CheckBoxAnswerIncludedTextException.java b/backend/src/main/java/reviewme/review/service/exception/CheckBoxAnswerIncludedTextException.java deleted file mode 100644 index a563acbf0..000000000 --- a/backend/src/main/java/reviewme/review/service/exception/CheckBoxAnswerIncludedTextException.java +++ /dev/null @@ -1,13 +0,0 @@ -package reviewme.review.service.exception; - -import lombok.extern.slf4j.Slf4j; -import reviewme.global.exception.BadRequestException; - -@Slf4j -public class CheckBoxAnswerIncludedTextException extends BadRequestException { - - public CheckBoxAnswerIncludedTextException(long questionId) { - super("체크박스형 응답은 텍스트를 포함할 수 없어요."); - log.info("CheckBox type answer cannot have option items - questionId: {}", questionId); - } -} diff --git a/backend/src/main/java/reviewme/review/service/exception/GatheredAnswersTypeNonUniformException.java b/backend/src/main/java/reviewme/review/service/exception/GatheredAnswersTypeNonUniformException.java index 3d13fd987..fd8293f3b 100644 --- a/backend/src/main/java/reviewme/review/service/exception/GatheredAnswersTypeNonUniformException.java +++ b/backend/src/main/java/reviewme/review/service/exception/GatheredAnswersTypeNonUniformException.java @@ -7,7 +7,7 @@ public class GatheredAnswersTypeNonUniformException extends DataInconsistencyException { public GatheredAnswersTypeNonUniformException(Throwable cause) { - super("서버 내부 오류가 발생했습니다."); + super("서버 내부 오류가 발생했어요."); log.error("The types of answers to questions are not uniform.", cause); } } diff --git a/backend/src/main/java/reviewme/review/service/exception/InvalidTextAnswerLengthException.java b/backend/src/main/java/reviewme/review/service/exception/InvalidTextAnswerLengthException.java index 01c02ceb7..314f72673 100644 --- a/backend/src/main/java/reviewme/review/service/exception/InvalidTextAnswerLengthException.java +++ b/backend/src/main/java/reviewme/review/service/exception/InvalidTextAnswerLengthException.java @@ -8,7 +8,7 @@ public class InvalidTextAnswerLengthException extends BadRequestException { public InvalidTextAnswerLengthException(long questionId, int answerLength, int minLength, int maxLength) { super("답변의 길이는 %d자 이상 %d자 이하여야 해요.".formatted(minLength, maxLength)); - log.warn("AnswerLength is out of bound - questionId: {}, answerLength: {}, minLength: {}, maxLength: {}", + log.info("AnswerLength is out of bound - questionId: {}, answerLength: {}, minLength: {}, maxLength: {}", questionId, answerLength, minLength, maxLength, this); } diff --git a/backend/src/main/java/reviewme/review/service/exception/QuestionNotContainingAnswersException.java b/backend/src/main/java/reviewme/review/service/exception/QuestionNotContainingAnswersException.java new file mode 100644 index 000000000..3a7740787 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/exception/QuestionNotContainingAnswersException.java @@ -0,0 +1,15 @@ +package reviewme.review.service.exception; + +import java.util.Collection; +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.ReviewMeException; + +@Slf4j +public class QuestionNotContainingAnswersException extends ReviewMeException { + + public QuestionNotContainingAnswersException(long questionId, Collection providedAnswerIds) { + super("질문에 속하지 않는 답변이예요."); + log.info("Question not containing provided answers - questionId: {}, providedAnswerIds: {}", + questionId, providedAnswerIds); + } +} diff --git a/backend/src/main/java/reviewme/review/service/exception/ReviewGroupNotContainingAnswersException.java b/backend/src/main/java/reviewme/review/service/exception/ReviewGroupNotContainingAnswersException.java new file mode 100644 index 000000000..7f641512f --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/exception/ReviewGroupNotContainingAnswersException.java @@ -0,0 +1,15 @@ +package reviewme.review.service.exception; + +import java.util.Collection; +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.ReviewMeException; + +@Slf4j +public class ReviewGroupNotContainingAnswersException extends ReviewMeException { + + public ReviewGroupNotContainingAnswersException(long reviewGroupId, Collection providedAnswerIds) { + super("리뷰 그룹에 속하지 않는 답변이예요."); + log.info("ReviewGroup not containing provided answers - reviewGroupId: {}, providedAnswerIds: {}", + reviewGroupId, providedAnswerIds); + } +} diff --git a/backend/src/main/java/reviewme/review/service/exception/SectionNotFoundInTemplateException.java b/backend/src/main/java/reviewme/review/service/exception/SectionNotFoundInTemplateException.java index 9941c8c8a..6b4e7eeb8 100644 --- a/backend/src/main/java/reviewme/review/service/exception/SectionNotFoundInTemplateException.java +++ b/backend/src/main/java/reviewme/review/service/exception/SectionNotFoundInTemplateException.java @@ -7,7 +7,7 @@ public class SectionNotFoundInTemplateException extends NotFoundException { public SectionNotFoundInTemplateException(long sectionId, long templateId) { - super("섹션 정보를 찾을 수 없습니다."); + super("섹션 정보를 찾을 수 없어요."); log.info("Section not found in template - sectionId: {}, templateId: {}", sectionId, templateId, this); } } 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 1924b1cf5..97b0f77d9 100644 --- a/backend/src/main/java/reviewme/review/service/exception/SubmittedQuestionAndProvidedQuestionMismatchException.java +++ b/backend/src/main/java/reviewme/review/service/exception/SubmittedQuestionAndProvidedQuestionMismatchException.java @@ -1,7 +1,6 @@ package reviewme.review.service.exception; import java.util.Collection; -import java.util.List; import lombok.extern.slf4j.Slf4j; import reviewme.global.exception.BadRequestException; @@ -16,9 +15,4 @@ public SubmittedQuestionAndProvidedQuestionMismatchException(Collection su submittedQuestionIds, providedQuestionIds, this ); } - - public SubmittedQuestionAndProvidedQuestionMismatchException(long submittedQuestionId, - Collection providedQuestionIds) { - this(List.of(submittedQuestionId), providedQuestionIds); - } } diff --git a/backend/src/main/java/reviewme/review/service/exception/TextAnswerIncludedOptionItemException.java b/backend/src/main/java/reviewme/review/service/exception/TextAnswerIncludedOptionItemException.java deleted file mode 100644 index ba9310ee6..000000000 --- a/backend/src/main/java/reviewme/review/service/exception/TextAnswerIncludedOptionItemException.java +++ /dev/null @@ -1,13 +0,0 @@ -package reviewme.review.service.exception; - -import lombok.extern.slf4j.Slf4j; -import reviewme.global.exception.BadRequestException; - -@Slf4j -public class TextAnswerIncludedOptionItemException extends BadRequestException { - - public TextAnswerIncludedOptionItemException(long questionId) { - super("텍스트형 응답은 옵션 항목을 포함할 수 없어요."); - log.info("Text type answer cannot have option items - questionId: {}", questionId); - } -} diff --git a/backend/src/main/java/reviewme/review/service/mapper/AnswerMapper.java b/backend/src/main/java/reviewme/review/service/mapper/AnswerMapper.java index 7b3cbb631..87ee4c511 100644 --- a/backend/src/main/java/reviewme/review/service/mapper/AnswerMapper.java +++ b/backend/src/main/java/reviewme/review/service/mapper/AnswerMapper.java @@ -1,8 +1,8 @@ package reviewme.review.service.mapper; -import reviewme.question.domain.QuestionType; import reviewme.review.domain.Answer; import reviewme.review.service.dto.request.ReviewAnswerRequest; +import reviewme.template.domain.QuestionType; public interface AnswerMapper { diff --git a/backend/src/main/java/reviewme/review/service/mapper/AnswerMapperFactory.java b/backend/src/main/java/reviewme/review/service/mapper/AnswerMapperFactory.java index 6dc804547..624c3ba81 100644 --- a/backend/src/main/java/reviewme/review/service/mapper/AnswerMapperFactory.java +++ b/backend/src/main/java/reviewme/review/service/mapper/AnswerMapperFactory.java @@ -3,7 +3,7 @@ import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; -import reviewme.question.domain.QuestionType; +import reviewme.template.domain.QuestionType; @Component @RequiredArgsConstructor diff --git a/backend/src/main/java/reviewme/review/service/mapper/CheckboxAnswerMapper.java b/backend/src/main/java/reviewme/review/service/mapper/CheckboxAnswerMapper.java index 3648e32f6..2829890cd 100644 --- a/backend/src/main/java/reviewme/review/service/mapper/CheckboxAnswerMapper.java +++ b/backend/src/main/java/reviewme/review/service/mapper/CheckboxAnswerMapper.java @@ -1,10 +1,9 @@ package reviewme.review.service.mapper; import org.springframework.stereotype.Component; -import reviewme.question.domain.QuestionType; import reviewme.review.domain.CheckboxAnswer; import reviewme.review.service.dto.request.ReviewAnswerRequest; -import reviewme.review.service.exception.CheckBoxAnswerIncludedTextException; +import reviewme.template.domain.QuestionType; @Component public class CheckboxAnswerMapper implements AnswerMapper { @@ -16,8 +15,8 @@ public boolean supports(QuestionType questionType) { @Override public CheckboxAnswer mapToAnswer(ReviewAnswerRequest answerRequest) { - if (answerRequest.text() != null) { - throw new CheckBoxAnswerIncludedTextException(answerRequest.questionId()); + if (answerRequest.hasNoSelectedOptions()) { + return null; } return new CheckboxAnswer(answerRequest.questionId(), answerRequest.selectedOptionIds()); } diff --git a/backend/src/main/java/reviewme/review/service/mapper/ReviewDetailMapper.java b/backend/src/main/java/reviewme/review/service/mapper/ReviewDetailMapper.java index 7121d99b5..3f5e8758a 100644 --- a/backend/src/main/java/reviewme/review/service/mapper/ReviewDetailMapper.java +++ b/backend/src/main/java/reviewme/review/service/mapper/ReviewDetailMapper.java @@ -3,61 +3,60 @@ import java.util.List; import java.util.Map; import java.util.Set; -import java.util.function.Function; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; -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.CheckboxAnswerSelectedOption; import reviewme.review.domain.Review; import reviewme.review.domain.TextAnswer; -import reviewme.review.service.dto.response.detail.OptionGroupAnswerResponse; import reviewme.review.service.dto.response.detail.OptionItemAnswerResponse; 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.reviewgroup.domain.ReviewGroup; +import reviewme.template.domain.OptionGroup; +import reviewme.template.domain.OptionItem; +import reviewme.template.domain.Question; import reviewme.template.domain.Section; -import reviewme.template.repository.SectionRepository; +import reviewme.template.domain.Template; +import reviewme.template.repository.TemplateRepository; +import reviewme.template.service.exception.TemplateNotFoundByReviewGroupException; @Component @RequiredArgsConstructor public class ReviewDetailMapper { - private final SectionRepository sectionRepository; - private final QuestionRepository questionRepository; - private final OptionGroupRepository optionGroupRepository; - private final OptionItemRepository optionItemRepository; + private final TemplateRepository templateRepository; + /* + TODO: + 조회 전용 로직을 만드는 게 좋겠다, Template + 리뷰 정보를 한 번에 내려줘야 한다. + Template에서 정보를 가져오는 건 쉽다 (연관관계 있음), 리뷰 관련 정보를 가져와서 어떻게 섞을지 고민하자. + */ public ReviewDetailResponse mapToReviewDetailResponse(Review review, ReviewGroup reviewGroup) { - long templateId = review.getTemplateId(); + Template template = templateRepository.findById(reviewGroup.getTemplateId()) + .orElseThrow(() -> new TemplateNotFoundByReviewGroupException(reviewGroup.getId(), + reviewGroup.getTemplateId())); - List
sections = sectionRepository.findAllByTemplateId(templateId); - List questions = questionRepository.findAllByTemplatedId(templateId); - List questionIds = questions.stream() - .map(Question::getId) + List
sections = template.getSections(); + List questions = sections.stream() + .flatMap(section -> section.getQuestions().stream()) .toList(); - Map optionGroupsByQuestion = optionGroupRepository.findAllByQuestionIds(questionIds) - .stream() - .collect(Collectors.toMap(OptionGroup::getQuestionId, Function.identity())); - Map> optionItemsByOptionGroup = optionItemRepository.findAllByQuestionIds(questionIds) - .stream() - .collect(Collectors.groupingBy(OptionItem::getOptionGroupId)); + Map optionGroupsByQuestion = questions.stream() + .filter(Question::isCheckbox) + .collect(Collectors.toMap(Question::getId, Question::getOptionGroup)); + Map> optionItemsByOptionGroup = optionGroupsByQuestion.values().stream() + .collect(Collectors.toMap(OptionGroup::getId, OptionGroup::getOptionItems)); List sectionResponses = sections.stream() .map(section -> mapToSectionResponse(review, section, questions, optionGroupsByQuestion, optionItemsByOptionGroup)) - .filter(sectionResponse -> !sectionResponse.questions().isEmpty()) + .filter(sectionResponse -> !sectionResponse.reviews().isEmpty()) .toList(); return new ReviewDetailResponse( - templateId, + template.getId(), reviewGroup.getReviewee(), reviewGroup.getProjectName(), review.getCreatedDate(), @@ -70,7 +69,7 @@ private SectionAnswerResponse mapToSectionResponse(Review review, Section sectio Map optionGroupsByQuestion, Map> optionItemsByOptionGroup) { List questionResponses = questions.stream() - .filter(question -> section.containsQuestionId(question.getId())) + .filter(section::contains) .filter(question -> review.hasAnsweredQuestion(question.getId())) .map(question -> mapToQuestionResponse( review, question, optionGroupsByQuestion, optionItemsByOptionGroup) @@ -86,7 +85,7 @@ private SectionAnswerResponse mapToSectionResponse(Review review, Section sectio private QuestionAnswerResponse mapToQuestionResponse(Review review, Question question, Map optionGroupsByQuestion, Map> optionItemsByOptionGroup) { - if (question.isSelectable()) { + if (question.isCheckbox()) { return mapToCheckboxQuestionResponse(review, question, optionGroupsByQuestion, optionItemsByOptionGroup); } else { return mapToTextQuestionResponse(review, question); @@ -107,22 +106,15 @@ private QuestionAnswerResponse mapToCheckboxQuestionResponse(Review review, List optionItemResponse = optionItems.stream() .filter(optionItem -> selectedOptionIds.contains(optionItem.getId())) - .map(optionItem -> new OptionItemAnswerResponse(optionItem.getId(), optionItem.getContent(), true)) + .map(optionItem -> new OptionItemAnswerResponse(optionItem.getId(), optionItem.getContent())) .toList(); - OptionGroupAnswerResponse optionGroupAnswerResponse = new OptionGroupAnswerResponse( - optionGroup.getId(), - optionGroup.getMinSelectionCount(), - optionGroup.getMaxSelectionCount(), - optionItemResponse - ); - return new QuestionAnswerResponse( question.getId(), question.isRequired(), question.getQuestionType(), question.getContent(), - optionGroupAnswerResponse, + optionItemResponse, null ); } 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 2a1f4e135..fd6471600 100644 --- a/backend/src/main/java/reviewme/review/service/mapper/ReviewGatherMapper.java +++ b/backend/src/main/java/reviewme/review/service/mapper/ReviewGatherMapper.java @@ -7,9 +7,9 @@ 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; +import reviewme.template.domain.OptionItem; +import reviewme.template.domain.Question; +import reviewme.template.repository.QuestionRepository; import reviewme.review.domain.Answer; import reviewme.review.domain.CheckboxAnswer; import reviewme.review.domain.CheckboxAnswerSelectedOption; @@ -51,7 +51,7 @@ private ReviewsGatheredByQuestionResponse mapToReviewsGatheredByQuestion(Questio @Nullable private List mapToTextResponse(Question question, List answers, List highlights) { - if (question.isSelectable()) { + if (question.isCheckbox()) { return null; } Map> answerIdHighlights = highlights.stream() @@ -84,7 +84,7 @@ private List mapToHighlightResponse(List highlight @Nullable private List mapToVoteResponse(Question question, List answers) { - if (!question.isSelectable()) { + if (!question.isCheckbox()) { return null; } diff --git a/backend/src/main/java/reviewme/review/service/mapper/ReviewListMapper.java b/backend/src/main/java/reviewme/review/service/mapper/ReviewListMapper.java index aa882802a..de46e8537 100644 --- a/backend/src/main/java/reviewme/review/service/mapper/ReviewListMapper.java +++ b/backend/src/main/java/reviewme/review/service/mapper/ReviewListMapper.java @@ -5,16 +5,16 @@ import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; -import reviewme.question.domain.OptionItem; -import reviewme.question.domain.OptionType; -import reviewme.question.repository.OptionItemRepository; +import reviewme.template.domain.OptionItem; +import reviewme.template.domain.OptionType; +import reviewme.template.repository.OptionItemRepository; import reviewme.review.domain.CheckboxAnswer; import reviewme.review.domain.CheckboxAnswerSelectedOption; import reviewme.review.domain.Review; import reviewme.review.domain.TextAnswer; import reviewme.review.repository.ReviewRepository; import reviewme.review.service.dto.response.list.ReviewCategoryResponse; -import reviewme.review.service.dto.response.list.ReviewListElementResponse; +import reviewme.review.service.dto.response.list.ReceivedReviewPageElementResponse; import reviewme.reviewgroup.domain.ReviewGroup; @Component @@ -26,7 +26,7 @@ public class ReviewListMapper { private final ReviewPreviewGenerator reviewPreviewGenerator = new ReviewPreviewGenerator(); - public List mapToReviewList(ReviewGroup reviewGroup, Long lastReviewId, int size) { + public List mapToReviewList(ReviewGroup reviewGroup, Long lastReviewId, int size) { List categoryOptionItems = optionItemRepository.findAllByOptionType(OptionType.CATEGORY); return reviewRepository.findByReviewGroupIdWithLimit(reviewGroup.getId(), lastReviewId, size) .stream() @@ -34,11 +34,11 @@ public List mapToReviewList(ReviewGroup reviewGroup, .toList(); } - private ReviewListElementResponse mapToReviewListElementResponse(Review review, - List categoryOptionItems) { + private ReceivedReviewPageElementResponse mapToReviewListElementResponse(Review review, + List categoryOptionItems) { List categoryResponses = mapToCategoryOptionResponse(review, categoryOptionItems); - return new ReviewListElementResponse( + return new ReceivedReviewPageElementResponse( review.getId(), review.getCreatedDate(), reviewPreviewGenerator.generatePreview(review.getAnswersByType(TextAnswer.class)), diff --git a/backend/src/main/java/reviewme/review/service/mapper/ReviewMapper.java b/backend/src/main/java/reviewme/review/service/mapper/ReviewMapper.java index 68ee776b9..0db3362ae 100644 --- a/backend/src/main/java/reviewme/review/service/mapper/ReviewMapper.java +++ b/backend/src/main/java/reviewme/review/service/mapper/ReviewMapper.java @@ -8,13 +8,13 @@ import lombok.AccessLevel; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; -import reviewme.question.domain.Question; -import reviewme.question.repository.QuestionRepository; +import reviewme.template.domain.Question; +import reviewme.template.repository.QuestionRepository; import reviewme.review.domain.Answer; import reviewme.review.domain.Review; import reviewme.review.service.dto.request.ReviewAnswerRequest; import reviewme.review.service.dto.request.ReviewRegisterRequest; -import reviewme.review.service.exception.ReviewGroupNotFoundByReviewRequestCodeException; +import reviewme.reviewgroup.service.exception.ReviewGroupNotFoundByReviewRequestCodeException; import reviewme.review.service.exception.SubmittedQuestionNotFoundException; import reviewme.reviewgroup.domain.ReviewGroup; import reviewme.reviewgroup.repository.ReviewGroupRepository; @@ -62,20 +62,10 @@ private List getAnswersByQuestionType(ReviewRegisterRequest request) { private Answer mapRequestToAnswer(Map questions, ReviewAnswerRequest answerRequest) { Question question = questions.get(answerRequest.questionId()); - if (question == null) { throw new SubmittedQuestionNotFoundException(answerRequest.questionId()); } - // TODO: 아래 코드를 삭제해야 한다 - if (question.isSelectable() && answerRequest.selectedOptionIds() != null && answerRequest.selectedOptionIds().isEmpty()) { - return null; - } - if (!question.isSelectable() && answerRequest.text() != null && answerRequest.text().isEmpty()) { - return null; - } - // END - AnswerMapper answerMapper = answerMapperFactory.getAnswerMapper(question.getQuestionType()); return answerMapper.mapToAnswer(answerRequest); } diff --git a/backend/src/main/java/reviewme/review/service/mapper/TextAnswerMapper.java b/backend/src/main/java/reviewme/review/service/mapper/TextAnswerMapper.java index afd47ac97..6f28faedd 100644 --- a/backend/src/main/java/reviewme/review/service/mapper/TextAnswerMapper.java +++ b/backend/src/main/java/reviewme/review/service/mapper/TextAnswerMapper.java @@ -1,10 +1,9 @@ package reviewme.review.service.mapper; import org.springframework.stereotype.Component; -import reviewme.question.domain.QuestionType; import reviewme.review.domain.TextAnswer; import reviewme.review.service.dto.request.ReviewAnswerRequest; -import reviewme.review.service.exception.TextAnswerIncludedOptionItemException; +import reviewme.template.domain.QuestionType; @Component public class TextAnswerMapper implements AnswerMapper { @@ -16,12 +15,9 @@ public boolean supports(QuestionType questionType) { @Override public TextAnswer mapToAnswer(ReviewAnswerRequest answerRequest) { - if (!answerRequest.hasTextAnswer()) { + if (answerRequest.hasNoText()) { return null; } - if (answerRequest.selectedOptionIds() != null) { - throw new TextAnswerIncludedOptionItemException(answerRequest.questionId()); - } return new TextAnswer(answerRequest.questionId(), answerRequest.text()); } } diff --git a/backend/src/main/java/reviewme/review/service/mapper/UnsupportedQuestionTypeException.java b/backend/src/main/java/reviewme/review/service/mapper/UnsupportedQuestionTypeException.java index b08870515..26a22f0fd 100644 --- a/backend/src/main/java/reviewme/review/service/mapper/UnsupportedQuestionTypeException.java +++ b/backend/src/main/java/reviewme/review/service/mapper/UnsupportedQuestionTypeException.java @@ -2,7 +2,7 @@ import lombok.extern.slf4j.Slf4j; import reviewme.global.exception.DataInconsistencyException; -import reviewme.question.domain.QuestionType; +import reviewme.template.domain.QuestionType; @Slf4j public class UnsupportedQuestionTypeException extends DataInconsistencyException { diff --git a/backend/src/main/java/reviewme/review/service/validator/AnswerValidator.java b/backend/src/main/java/reviewme/review/service/validator/AnswerValidator.java index 11162cc26..bb9f85434 100644 --- a/backend/src/main/java/reviewme/review/service/validator/AnswerValidator.java +++ b/backend/src/main/java/reviewme/review/service/validator/AnswerValidator.java @@ -1,10 +1,31 @@ package reviewme.review.service.validator; -import reviewme.review.domain.Answer; +import java.util.Collection; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import reviewme.review.repository.AnswerRepository; +import reviewme.review.service.exception.QuestionNotContainingAnswersException; +import reviewme.review.service.exception.ReviewGroupNotContainingAnswersException; +import reviewme.reviewgroup.domain.ReviewGroup; -public interface AnswerValidator { +@Component +@RequiredArgsConstructor +public class AnswerValidator { - boolean supports(Class answerClass); + private final AnswerRepository answerRepository; - void validate(Answer answer); + public void validateQuestionContainsAnswers(long questionId, Collection answerIds) { + Set receivedAnswerIds = answerRepository.findIdsByQuestionId(questionId); + if (!receivedAnswerIds.containsAll(answerIds)) { + throw new QuestionNotContainingAnswersException(questionId, answerIds); + } + } + + public void validateReviewGroupContainsAnswers(ReviewGroup reviewGroup, Collection answerIds) { + Set receivedAnswerIds = answerRepository.findIdsByReviewGroupId(reviewGroup.getId()); + if (!receivedAnswerIds.containsAll(answerIds)) { + throw new ReviewGroupNotContainingAnswersException(reviewGroup.getId(), answerIds); + } + } } diff --git a/backend/src/main/java/reviewme/review/service/validator/CheckboxAnswerValidator.java b/backend/src/main/java/reviewme/review/service/validator/CheckboxTypedAnswerValidator.java similarity index 82% rename from backend/src/main/java/reviewme/review/service/validator/CheckboxAnswerValidator.java rename to backend/src/main/java/reviewme/review/service/validator/CheckboxTypedAnswerValidator.java index 62d39728b..3ccead908 100644 --- a/backend/src/main/java/reviewme/review/service/validator/CheckboxAnswerValidator.java +++ b/backend/src/main/java/reviewme/review/service/validator/CheckboxTypedAnswerValidator.java @@ -5,12 +5,13 @@ import lombok.AccessLevel; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; -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.template.domain.OptionGroup; +import reviewme.template.domain.SelectionRange; +import reviewme.template.domain.OptionItem; +import reviewme.template.domain.Question; +import reviewme.template.repository.OptionGroupRepository; +import reviewme.template.repository.OptionItemRepository; +import reviewme.template.repository.QuestionRepository; import reviewme.review.domain.Answer; import reviewme.review.domain.CheckboxAnswerSelectedOption; import reviewme.review.domain.CheckboxAnswer; @@ -21,7 +22,7 @@ @Component @RequiredArgsConstructor(access = AccessLevel.PROTECTED) -public class CheckboxAnswerValidator implements AnswerValidator { +public class CheckboxTypedAnswerValidator implements TypedAnswerValidator { private final QuestionRepository questionRepository; private final OptionGroupRepository optionGroupRepository; @@ -60,15 +61,15 @@ private void validateOnlyIncludingProvidedOptionItem(CheckboxAnswer checkboxAnsw } private void validateCheckedOptionItemCount(CheckboxAnswer checkboxAnswer, OptionGroup optionGroup) { + SelectionRange selectionRange = optionGroup.getSelectionRange(); int answeredOptionItemCount = extractAnsweredOptionItemIds(checkboxAnswer).size(); - if (answeredOptionItemCount < optionGroup.getMinSelectionCount() - || answeredOptionItemCount > optionGroup.getMaxSelectionCount()) { + if (selectionRange.isOutOfRange(answeredOptionItemCount)) { throw new SelectedOptionItemCountOutOfRangeException( checkboxAnswer.getQuestionId(), answeredOptionItemCount, - optionGroup.getMinSelectionCount(), - optionGroup.getMaxSelectionCount() + selectionRange.getMinSelectionCount(), + selectionRange.getMaxSelectionCount() ); } } diff --git a/backend/src/main/java/reviewme/review/service/validator/ReviewValidator.java b/backend/src/main/java/reviewme/review/service/validator/ReviewValidator.java index 2906a8507..a480b3d9f 100644 --- a/backend/src/main/java/reviewme/review/service/validator/ReviewValidator.java +++ b/backend/src/main/java/reviewme/review/service/validator/ReviewValidator.java @@ -7,23 +7,22 @@ import lombok.AccessLevel; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; -import reviewme.question.domain.Question; -import reviewme.question.repository.QuestionRepository; import reviewme.review.domain.Answer; -import reviewme.review.domain.CheckboxAnswerSelectedOption; import reviewme.review.domain.CheckboxAnswer; +import reviewme.review.domain.CheckboxAnswerSelectedOption; import reviewme.review.domain.Review; import reviewme.review.service.exception.MissingRequiredQuestionException; import reviewme.review.service.exception.SubmittedQuestionAndProvidedQuestionMismatchException; +import reviewme.template.domain.Question; import reviewme.template.domain.Section; -import reviewme.template.domain.SectionQuestion; +import reviewme.template.repository.QuestionRepository; import reviewme.template.repository.SectionRepository; @Component @RequiredArgsConstructor(access = AccessLevel.PROTECTED) public class ReviewValidator { - private final AnswerValidatorFactory answerValidatorFactory; + private final TypedAnswerValidatorFactory typedAnswerValidatorFactory; private final SectionRepository sectionRepository; private final QuestionRepository questionRepository; @@ -36,8 +35,8 @@ public void validate(Review review) { private void validateAnswer(List answers) { for (Answer answer : answers) { - AnswerValidator validator = answerValidatorFactory.getAnswerValidator(answer.getClass()); - validator.validate(answer); + typedAnswerValidatorFactory.getAnswerValidator(answer.getClass()) + .validate(answer); } } @@ -75,8 +74,8 @@ private Set extractDisplayedQuestionIds(Review review) { return sections.stream() .filter(section -> section.isVisibleBySelectedOptionIds(selectedOptionIds)) - .flatMap(section -> section.getQuestionIds().stream()) - .map(SectionQuestion::getQuestionId) + .flatMap(section -> section.getQuestions().stream()) + .map(Question::getId) .collect(Collectors.toSet()); } } diff --git a/backend/src/main/java/reviewme/review/service/validator/TextAnswerValidator.java b/backend/src/main/java/reviewme/review/service/validator/TextTypedAnswerValidator.java similarity index 88% rename from backend/src/main/java/reviewme/review/service/validator/TextAnswerValidator.java rename to backend/src/main/java/reviewme/review/service/validator/TextTypedAnswerValidator.java index 78a0701dd..0dffa56c1 100644 --- a/backend/src/main/java/reviewme/review/service/validator/TextAnswerValidator.java +++ b/backend/src/main/java/reviewme/review/service/validator/TextTypedAnswerValidator.java @@ -3,8 +3,8 @@ import lombok.AccessLevel; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; -import reviewme.question.domain.Question; -import reviewme.question.repository.QuestionRepository; +import reviewme.template.domain.Question; +import reviewme.template.repository.QuestionRepository; import reviewme.review.domain.Answer; import reviewme.review.domain.TextAnswer; import reviewme.review.service.exception.InvalidTextAnswerLengthException; @@ -12,9 +12,8 @@ @Component @RequiredArgsConstructor(access = AccessLevel.PROTECTED) -public class TextAnswerValidator implements AnswerValidator { +public class TextTypedAnswerValidator implements TypedAnswerValidator { - private static final int ZERO_LENGTH = 0; private static final int MIN_LENGTH = 20; private static final int MAX_LENGTH = 1_000; diff --git a/backend/src/main/java/reviewme/review/service/validator/TypedAnswerValidator.java b/backend/src/main/java/reviewme/review/service/validator/TypedAnswerValidator.java new file mode 100644 index 000000000..2bc060c52 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/validator/TypedAnswerValidator.java @@ -0,0 +1,10 @@ +package reviewme.review.service.validator; + +import reviewme.review.domain.Answer; + +public interface TypedAnswerValidator { + + boolean supports(Class answerClass); + + void validate(Answer answer); +} diff --git a/backend/src/main/java/reviewme/review/service/validator/AnswerValidatorFactory.java b/backend/src/main/java/reviewme/review/service/validator/TypedAnswerValidatorFactory.java similarity index 68% rename from backend/src/main/java/reviewme/review/service/validator/AnswerValidatorFactory.java rename to backend/src/main/java/reviewme/review/service/validator/TypedAnswerValidatorFactory.java index b1adc5933..a0ff54733 100644 --- a/backend/src/main/java/reviewme/review/service/validator/AnswerValidatorFactory.java +++ b/backend/src/main/java/reviewme/review/service/validator/TypedAnswerValidatorFactory.java @@ -8,12 +8,12 @@ @Component @RequiredArgsConstructor(access = AccessLevel.PROTECTED) -public class AnswerValidatorFactory { +public class TypedAnswerValidatorFactory { - private final List answerValidators; + private final List validators; - public AnswerValidator getAnswerValidator(Class answerClass) { - return answerValidators.stream() + public TypedAnswerValidator getAnswerValidator(Class answerClass) { + return validators.stream() .filter(validator -> validator.supports(answerClass)) .findFirst() .orElseThrow(() -> new UnsupportedAnswerTypeException(answerClass)); diff --git a/backend/src/main/java/reviewme/reviewgroup/controller/ReviewGroupController.java b/backend/src/main/java/reviewme/reviewgroup/controller/ReviewGroupController.java index b6c7a973c..fa82ba70d 100644 --- a/backend/src/main/java/reviewme/reviewgroup/controller/ReviewGroupController.java +++ b/backend/src/main/java/reviewme/reviewgroup/controller/ReviewGroupController.java @@ -15,6 +15,7 @@ import reviewme.reviewgroup.service.dto.CheckValidAccessRequest; import reviewme.reviewgroup.service.dto.ReviewGroupCreationRequest; import reviewme.reviewgroup.service.dto.ReviewGroupCreationResponse; +import reviewme.reviewgroup.service.dto.ReviewGroupPageResponse; import reviewme.reviewgroup.service.dto.ReviewGroupResponse; @RestController @@ -24,7 +25,7 @@ public class ReviewGroupController { private final ReviewGroupService reviewGroupService; private final ReviewGroupLookupService reviewGroupLookupService; - @GetMapping("/v2/groups") + @GetMapping("/v2/groups/summary") public ResponseEntity getReviewGroupSummary(@RequestParam String reviewRequestCode) { ReviewGroupResponse response = reviewGroupLookupService.getReviewGroupSummary(reviewRequestCode); return ResponseEntity.ok(response); @@ -34,6 +35,7 @@ public ResponseEntity getReviewGroupSummary(@RequestParam S public ResponseEntity createReviewGroup( @Valid @RequestBody ReviewGroupCreationRequest request ) { + // 회원 세션 추후 추가해야 함 ReviewGroupCreationResponse response = reviewGroupService.createReviewGroup(request); return ResponseEntity.ok(response); } @@ -48,4 +50,11 @@ public ResponseEntity checkGroupAccessCode( session.setAttribute("reviewRequestCode", request.reviewRequestCode()); return ResponseEntity.noContent().build(); } + + @GetMapping("/v2/groups") + public ResponseEntity getMyReviewGroups() { + // TODO: 세션을 활용한 권한 체계에 따른 추가 조치 필요 + ReviewGroupPageResponse response = reviewGroupLookupService.getMyReviewGroups(); + return ResponseEntity.ok(response); + } } diff --git a/backend/src/main/java/reviewme/reviewgroup/domain/ReviewGroup.java b/backend/src/main/java/reviewme/reviewgroup/domain/ReviewGroup.java index dcc97fefe..6e95967bd 100644 --- a/backend/src/main/java/reviewme/reviewgroup/domain/ReviewGroup.java +++ b/backend/src/main/java/reviewme/reviewgroup/domain/ReviewGroup.java @@ -11,8 +11,8 @@ import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NoArgsConstructor; -import reviewme.review.domain.exception.InvalidProjectNameLengthException; -import reviewme.review.domain.exception.InvalidRevieweeNameLengthException; +import reviewme.reviewgroup.domain.exception.InvalidProjectNameLengthException; +import reviewme.reviewgroup.domain.exception.InvalidRevieweeNameLengthException; @Entity @Table(name = "review_group") diff --git a/backend/src/main/java/reviewme/review/domain/exception/InvalidProjectNameLengthException.java b/backend/src/main/java/reviewme/reviewgroup/domain/exception/InvalidProjectNameLengthException.java similarity index 92% rename from backend/src/main/java/reviewme/review/domain/exception/InvalidProjectNameLengthException.java rename to backend/src/main/java/reviewme/reviewgroup/domain/exception/InvalidProjectNameLengthException.java index 2e6386bb4..75b56ba43 100644 --- a/backend/src/main/java/reviewme/review/domain/exception/InvalidProjectNameLengthException.java +++ b/backend/src/main/java/reviewme/reviewgroup/domain/exception/InvalidProjectNameLengthException.java @@ -1,4 +1,4 @@ -package reviewme.review.domain.exception; +package reviewme.reviewgroup.domain.exception; import lombok.extern.slf4j.Slf4j; import reviewme.global.exception.BadRequestException; diff --git a/backend/src/main/java/reviewme/review/domain/exception/InvalidRevieweeNameLengthException.java b/backend/src/main/java/reviewme/reviewgroup/domain/exception/InvalidRevieweeNameLengthException.java similarity index 92% rename from backend/src/main/java/reviewme/review/domain/exception/InvalidRevieweeNameLengthException.java rename to backend/src/main/java/reviewme/reviewgroup/domain/exception/InvalidRevieweeNameLengthException.java index 27408d15b..e77562a23 100644 --- a/backend/src/main/java/reviewme/review/domain/exception/InvalidRevieweeNameLengthException.java +++ b/backend/src/main/java/reviewme/reviewgroup/domain/exception/InvalidRevieweeNameLengthException.java @@ -1,4 +1,4 @@ -package reviewme.review.domain.exception; +package reviewme.reviewgroup.domain.exception; import lombok.extern.slf4j.Slf4j; import reviewme.global.exception.BadRequestException; diff --git a/backend/src/main/java/reviewme/reviewgroup/service/ReviewGroupLookupService.java b/backend/src/main/java/reviewme/reviewgroup/service/ReviewGroupLookupService.java index 38bd711af..479b59116 100644 --- a/backend/src/main/java/reviewme/reviewgroup/service/ReviewGroupLookupService.java +++ b/backend/src/main/java/reviewme/reviewgroup/service/ReviewGroupLookupService.java @@ -3,7 +3,8 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import reviewme.review.service.exception.ReviewGroupNotFoundByReviewRequestCodeException; +import reviewme.reviewgroup.service.dto.ReviewGroupPageResponse; +import reviewme.reviewgroup.service.exception.ReviewGroupNotFoundByReviewRequestCodeException; import reviewme.reviewgroup.domain.ReviewGroup; import reviewme.reviewgroup.repository.ReviewGroupRepository; import reviewme.reviewgroup.service.dto.ReviewGroupResponse; @@ -19,6 +20,11 @@ public ReviewGroupResponse getReviewGroupSummary(String reviewRequestCode) { ReviewGroup reviewGroup = reviewGroupRepository.findByReviewRequestCode(reviewRequestCode) .orElseThrow(() -> new ReviewGroupNotFoundByReviewRequestCodeException(reviewRequestCode)); - return new ReviewGroupResponse(reviewGroup.getReviewee(), reviewGroup.getProjectName()); + return new ReviewGroupResponse(null, reviewGroup.getReviewee(), reviewGroup.getProjectName()); + } + + public ReviewGroupPageResponse getMyReviewGroups() { + // TODO: 생성일자 최신순 정렬 + return null; } } diff --git a/backend/src/main/java/reviewme/reviewgroup/service/ReviewGroupService.java b/backend/src/main/java/reviewme/reviewgroup/service/ReviewGroupService.java index 1ae76f6a0..fb09f6cb0 100644 --- a/backend/src/main/java/reviewme/reviewgroup/service/ReviewGroupService.java +++ b/backend/src/main/java/reviewme/reviewgroup/service/ReviewGroupService.java @@ -3,13 +3,13 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -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; import reviewme.reviewgroup.service.dto.ReviewGroupCreationResponse; +import reviewme.reviewgroup.service.exception.ReviewGroupNotFoundByReviewRequestCodeException; +import reviewme.reviewgroup.service.exception.ReviewGroupUnauthorizedException; import reviewme.template.domain.Template; import reviewme.template.repository.TemplateRepository; import reviewme.template.service.exception.TemplateNotFoundException; @@ -27,6 +27,7 @@ public class ReviewGroupService { @Transactional public ReviewGroupCreationResponse createReviewGroup(ReviewGroupCreationRequest request) { + // 회원, 비회원 분기 처리 필요 String reviewRequestCode; do { reviewRequestCode = randomCodeGenerator.generate(REVIEW_REQUEST_CODE_LENGTH); diff --git a/backend/src/main/java/reviewme/reviewgroup/service/dto/CheckValidAccessResponse.java b/backend/src/main/java/reviewme/reviewgroup/service/dto/CheckValidAccessResponse.java deleted file mode 100644 index 01444a880..000000000 --- a/backend/src/main/java/reviewme/reviewgroup/service/dto/CheckValidAccessResponse.java +++ /dev/null @@ -1,6 +0,0 @@ -package reviewme.reviewgroup.service.dto; - -public record CheckValidAccessResponse( - boolean hasAccess -) { -} diff --git a/backend/src/main/java/reviewme/reviewgroup/service/dto/MemberReviewGroupCreationRequest.java b/backend/src/main/java/reviewme/reviewgroup/service/dto/MemberReviewGroupCreationRequest.java new file mode 100644 index 000000000..c76c3c19c --- /dev/null +++ b/backend/src/main/java/reviewme/reviewgroup/service/dto/MemberReviewGroupCreationRequest.java @@ -0,0 +1,13 @@ +package reviewme.reviewgroup.service.dto; + +import jakarta.validation.constraints.NotEmpty; + +public record MemberReviewGroupCreationRequest( + + @NotEmpty(message = "리뷰이 이름을 입력해주세요.") + String revieweeName, + + @NotEmpty(message = "프로젝트 이름을 입력해주세요.") + String projectName +) { +} diff --git a/backend/src/main/java/reviewme/reviewgroup/service/dto/ReviewGroupCreationRequest.java b/backend/src/main/java/reviewme/reviewgroup/service/dto/ReviewGroupCreationRequest.java index c31a70f04..d8c1a0a0b 100644 --- a/backend/src/main/java/reviewme/reviewgroup/service/dto/ReviewGroupCreationRequest.java +++ b/backend/src/main/java/reviewme/reviewgroup/service/dto/ReviewGroupCreationRequest.java @@ -1,6 +1,6 @@ package reviewme.reviewgroup.service.dto; -import jakarta.validation.constraints.NotBlank; +import jakarta.annotation.Nullable; import jakarta.validation.constraints.NotEmpty; public record ReviewGroupCreationRequest( @@ -11,7 +11,7 @@ public record ReviewGroupCreationRequest( @NotEmpty(message = "프로젝트 이름을 입력해주세요.") String projectName, - @NotBlank(message = "비밀번호를 입력해주세요.") + @Nullable String groupAccessCode ) { } diff --git a/backend/src/main/java/reviewme/reviewgroup/service/dto/ReviewGroupPageElementResponse.java b/backend/src/main/java/reviewme/reviewgroup/service/dto/ReviewGroupPageElementResponse.java new file mode 100644 index 000000000..f5a7aab78 --- /dev/null +++ b/backend/src/main/java/reviewme/reviewgroup/service/dto/ReviewGroupPageElementResponse.java @@ -0,0 +1,12 @@ +package reviewme.reviewgroup.service.dto; + +import java.time.LocalDate; + +public record ReviewGroupPageElementResponse( + String revieweeName, + String projectName, + LocalDate createdAt, + String reviewRequestCode, + int reviewCount +) { +} diff --git a/backend/src/main/java/reviewme/reviewgroup/service/dto/ReviewGroupPageResponse.java b/backend/src/main/java/reviewme/reviewgroup/service/dto/ReviewGroupPageResponse.java new file mode 100644 index 000000000..ef6c250e9 --- /dev/null +++ b/backend/src/main/java/reviewme/reviewgroup/service/dto/ReviewGroupPageResponse.java @@ -0,0 +1,10 @@ +package reviewme.reviewgroup.service.dto; + +import java.util.List; + +public record ReviewGroupPageResponse( + long lastReviewGroupId, + boolean isLastPage, + List reviewGroups +) { +} diff --git a/backend/src/main/java/reviewme/reviewgroup/service/dto/ReviewGroupResponse.java b/backend/src/main/java/reviewme/reviewgroup/service/dto/ReviewGroupResponse.java index ea6f12a29..4d1accfbf 100644 --- a/backend/src/main/java/reviewme/reviewgroup/service/dto/ReviewGroupResponse.java +++ b/backend/src/main/java/reviewme/reviewgroup/service/dto/ReviewGroupResponse.java @@ -1,7 +1,10 @@ package reviewme.reviewgroup.service.dto; +import jakarta.annotation.Nullable; + public record ReviewGroupResponse( + @Nullable Long revieweeId, String revieweeName, String projectName ) { diff --git a/backend/src/main/java/reviewme/review/service/exception/ReviewGroupNotFoundByReviewRequestCodeException.java b/backend/src/main/java/reviewme/reviewgroup/service/exception/ReviewGroupNotFoundByReviewRequestCodeException.java similarity index 90% rename from backend/src/main/java/reviewme/review/service/exception/ReviewGroupNotFoundByReviewRequestCodeException.java rename to backend/src/main/java/reviewme/reviewgroup/service/exception/ReviewGroupNotFoundByReviewRequestCodeException.java index 121296482..5761e3c8d 100644 --- a/backend/src/main/java/reviewme/review/service/exception/ReviewGroupNotFoundByReviewRequestCodeException.java +++ b/backend/src/main/java/reviewme/reviewgroup/service/exception/ReviewGroupNotFoundByReviewRequestCodeException.java @@ -1,4 +1,4 @@ -package reviewme.review.service.exception; +package reviewme.reviewgroup.service.exception; import lombok.extern.slf4j.Slf4j; import reviewme.global.exception.NotFoundException; diff --git a/backend/src/main/java/reviewme/review/service/exception/ReviewGroupUnauthorizedException.java b/backend/src/main/java/reviewme/reviewgroup/service/exception/ReviewGroupUnauthorizedException.java similarity index 92% rename from backend/src/main/java/reviewme/review/service/exception/ReviewGroupUnauthorizedException.java rename to backend/src/main/java/reviewme/reviewgroup/service/exception/ReviewGroupUnauthorizedException.java index 125f2e7e9..64c106e84 100644 --- a/backend/src/main/java/reviewme/review/service/exception/ReviewGroupUnauthorizedException.java +++ b/backend/src/main/java/reviewme/reviewgroup/service/exception/ReviewGroupUnauthorizedException.java @@ -1,4 +1,4 @@ -package reviewme.review.service.exception; +package reviewme.reviewgroup.service.exception; import lombok.extern.slf4j.Slf4j; import reviewme.global.exception.UnauthorizedException; diff --git a/backend/src/main/java/reviewme/template/controller/SectionController.java b/backend/src/main/java/reviewme/template/controller/SectionController.java index 23826d87f..cf1e1b467 100644 --- a/backend/src/main/java/reviewme/template/controller/SectionController.java +++ b/backend/src/main/java/reviewme/template/controller/SectionController.java @@ -6,20 +6,20 @@ import org.springframework.web.bind.annotation.RestController; import reviewme.reviewgroup.controller.ReviewGroupSession; import reviewme.reviewgroup.domain.ReviewGroup; -import reviewme.template.service.SectionService; +import reviewme.template.service.TemplateService; import reviewme.template.service.dto.response.SectionNamesResponse; @RestController @RequiredArgsConstructor public class SectionController { - private final SectionService sectionService; + private final TemplateService templateService; @GetMapping("/v2/sections") public ResponseEntity getSectionNames( @ReviewGroupSession ReviewGroup reviewGroup ) { - SectionNamesResponse sectionNames = sectionService.getSectionNames(reviewGroup); + SectionNamesResponse sectionNames = templateService.getSectionNames(reviewGroup); return ResponseEntity.ok(sectionNames); } } diff --git a/backend/src/main/java/reviewme/template/domain/EmptyOptionGroupException.java b/backend/src/main/java/reviewme/template/domain/EmptyOptionGroupException.java new file mode 100644 index 000000000..c34315d82 --- /dev/null +++ b/backend/src/main/java/reviewme/template/domain/EmptyOptionGroupException.java @@ -0,0 +1,13 @@ +package reviewme.template.domain; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.BadRequestException; + +@Slf4j +public class EmptyOptionGroupException extends BadRequestException { + + public EmptyOptionGroupException() { + super("옵션 아이템은 최소 한 개 이상이어야 해요."); + log.info("OptionItems were empty while creating Option Group."); + } +} diff --git a/backend/src/main/java/reviewme/template/domain/InvalidSelectionRangeException.java b/backend/src/main/java/reviewme/template/domain/InvalidSelectionRangeException.java new file mode 100644 index 000000000..1d0b0fa8d --- /dev/null +++ b/backend/src/main/java/reviewme/template/domain/InvalidSelectionRangeException.java @@ -0,0 +1,20 @@ +package reviewme.template.domain; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.BadRequestException; + +@Slf4j +public class InvalidSelectionRangeException extends BadRequestException { + + public InvalidSelectionRangeException(int size, int minSelectionCount, int maxSelectionCount) { + super("선택 가능 범위가 잘못 설정되었어요."); + log.info("Invalid selection range on OptionGroup: OptionGroup size={}, minSelectionCount={}, maxSelectionCount={}", + size, minSelectionCount, maxSelectionCount); + } + + public InvalidSelectionRangeException(int minSelectionCount, int maxSelectionCount) { + super("선택 가능 범위가 잘못 설정되었어요."); + log.info("Invalid selection range: minSelectionCount={}, maxSelectionCount={}", + minSelectionCount, maxSelectionCount); + } +} diff --git a/backend/src/main/java/reviewme/template/domain/OptionGroup.java b/backend/src/main/java/reviewme/template/domain/OptionGroup.java new file mode 100644 index 000000000..a71db79c7 --- /dev/null +++ b/backend/src/main/java/reviewme/template/domain/OptionGroup.java @@ -0,0 +1,46 @@ +package reviewme.template.domain; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import java.util.List; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "option_group") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EqualsAndHashCode(of = "id") +@Getter +public class OptionGroup { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) + @JoinColumn(name = "option_group_id", nullable = false, updatable = false) + private List optionItems; + + @Embedded + private SelectionRange selectionRange; + + public OptionGroup(List optionItems, int minSelectionCount, int maxSelectionCount) { + if (optionItems.isEmpty()) { + throw new EmptyOptionGroupException(); + } + this.optionItems = optionItems; + this.selectionRange = new SelectionRange(minSelectionCount, maxSelectionCount); + if (optionItems.size() < maxSelectionCount) { + throw new InvalidSelectionRangeException(optionItems.size(), minSelectionCount, maxSelectionCount); + } + } +} diff --git a/backend/src/main/java/reviewme/question/domain/OptionItem.java b/backend/src/main/java/reviewme/template/domain/OptionItem.java similarity index 79% rename from backend/src/main/java/reviewme/question/domain/OptionItem.java rename to backend/src/main/java/reviewme/template/domain/OptionItem.java index 59b29bc3b..b76782a67 100644 --- a/backend/src/main/java/reviewme/question/domain/OptionItem.java +++ b/backend/src/main/java/reviewme/template/domain/OptionItem.java @@ -1,4 +1,4 @@ -package reviewme.question.domain; +package reviewme.template.domain; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -27,19 +27,16 @@ public class OptionItem { @Column(name = "content", nullable = false) private String content; - @Column(name = "option_group_id", nullable = false) - private long optionGroupId; - @Column(name = "position", nullable = false) private int position; + // TODO: 카테고리/키워드 여부는 도메인 로직이 아니라 서비스단에 있는 것이 자연스럽다고 생각함 @Column(name = "option_type", nullable = false) @Enumerated(EnumType.STRING) private OptionType optionType; - public OptionItem(String content, long optionGroupId, int position, OptionType optionType) { + public OptionItem(String content, int position, OptionType optionType) { this.content = content; - this.optionGroupId = optionGroupId; this.position = position; this.optionType = optionType; } diff --git a/backend/src/main/java/reviewme/question/domain/OptionType.java b/backend/src/main/java/reviewme/template/domain/OptionType.java similarity index 57% rename from backend/src/main/java/reviewme/question/domain/OptionType.java rename to backend/src/main/java/reviewme/template/domain/OptionType.java index dfa86920b..cf20adc26 100644 --- a/backend/src/main/java/reviewme/question/domain/OptionType.java +++ b/backend/src/main/java/reviewme/template/domain/OptionType.java @@ -1,6 +1,8 @@ -package reviewme.question.domain; +package reviewme.template.domain; public enum OptionType { + CATEGORY, KEYWORD, + ; } diff --git a/backend/src/main/java/reviewme/question/domain/Question.java b/backend/src/main/java/reviewme/template/domain/Question.java similarity index 57% rename from backend/src/main/java/reviewme/question/domain/Question.java rename to backend/src/main/java/reviewme/template/domain/Question.java index f59e4ae87..6ca22f979 100644 --- a/backend/src/main/java/reviewme/question/domain/Question.java +++ b/backend/src/main/java/reviewme/template/domain/Question.java @@ -1,5 +1,6 @@ -package reviewme.question.domain; +package reviewme.template.domain; +import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; @@ -7,6 +8,7 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.OneToOne; import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.EqualsAndHashCode; @@ -31,6 +33,9 @@ public class Question { @Enumerated(EnumType.STRING) private QuestionType questionType; + @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true) + private OptionGroup optionGroup; + @Column(name = "content", nullable = false, length = 1_000) private String content; @@ -40,15 +45,26 @@ public class Question { @Column(name = "position", nullable = false) private int position; - public Question(boolean required, QuestionType questionType, String content, String guideline, int position) { + // 질문 타입에 따른 Factory가 필요할 수 있다. Checkbox인 경우 OptionGroup을 가지게 하고, Text인 경우 그렇지 않고... + // Required도 마찬가지로 Factory에서 설정해준다면 content, guideline, position과 같은 필수적인 정보만 생성자에 넣어주면 된다. + // 사실 Position도 List의 순서에 따라 자동으로 배정하면 좋겠다. 같은 Section 안에 같은 position을 가질 수 없다는 불변식이 깨질 위험이 존재한다. + // TODO: @OrderColumn을 사용해 Position 사용하지 않고 자동 설정 + // TODO: QuestionType에 따른 검증 로직 추가 + public Question(boolean required, QuestionType questionType, OptionGroup optionGroup, + String content, String guideline, int position) { this.required = required; this.questionType = questionType; + this.optionGroup = optionGroup; this.content = content; this.guideline = guideline; this.position = position; } - public boolean isSelectable() { + public Question(boolean required, QuestionType questionType, String content, String guideline, int position) { + this(required, questionType, null, content, guideline, position); + } + + public boolean isCheckbox() { return questionType == QuestionType.CHECKBOX; } diff --git a/backend/src/main/java/reviewme/question/domain/QuestionType.java b/backend/src/main/java/reviewme/template/domain/QuestionType.java similarity index 64% rename from backend/src/main/java/reviewme/question/domain/QuestionType.java rename to backend/src/main/java/reviewme/template/domain/QuestionType.java index 863ba56e5..4213ce925 100644 --- a/backend/src/main/java/reviewme/question/domain/QuestionType.java +++ b/backend/src/main/java/reviewme/template/domain/QuestionType.java @@ -1,8 +1,8 @@ -package reviewme.question.domain; +package reviewme.template.domain; public enum QuestionType { + CHECKBOX, TEXT, ; - } diff --git a/backend/src/main/java/reviewme/template/domain/Section.java b/backend/src/main/java/reviewme/template/domain/Section.java index b9fa82b16..198f7812c 100644 --- a/backend/src/main/java/reviewme/template/domain/Section.java +++ b/backend/src/main/java/reviewme/template/domain/Section.java @@ -10,6 +10,7 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; import jakarta.persistence.OneToMany; import jakarta.persistence.Table; import java.util.Collection; @@ -36,10 +37,11 @@ public class Section { @OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL, orphanRemoval = true) @JoinColumn(name = "section_id", nullable = false, updatable = false) - private List questionIds; + private List questions; - @Column(name = "on_selected_option_id", nullable = true) - private Long onSelectedOptionId; + @ManyToOne(fetch = FetchType.LAZY, optional = true) + @JoinColumn(name = "on_selected_option_id", nullable = true) + private OptionItem onSelectedOption; @Column(name = "section_name", nullable = false) private String sectionName; @@ -50,24 +52,31 @@ public class Section { @Column(name = "position", nullable = false) private int position; - public Section(VisibleType visibleType, List questionIds, - Long onSelectedOptionId, String sectionName, String header, int position) { + public Section(VisibleType visibleType, List questions, + OptionItem onSelectedOption, String sectionName, String header, int position) { + if (questions.isEmpty()) { + throw new IllegalArgumentException("질문은 최소 한 개 이상이어야 합니다."); + } + if (visibleType == VisibleType.CONDITIONAL && onSelectedOption == null) { + throw new IllegalArgumentException("조건부 표시인 경우 선택 옵션이 필수입니다."); + } this.visibleType = visibleType; - this.questionIds = questionIds.stream() - .map(SectionQuestion::new) - .toList(); - this.onSelectedOptionId = onSelectedOptionId; + this.questions = questions; + this.onSelectedOption = onSelectedOption; this.sectionName = sectionName; this.header = header; this.position = position; } public boolean isVisibleBySelectedOptionIds(Collection selectedOptionIds) { - return visibleType == VisibleType.ALWAYS || selectedOptionIds.contains(onSelectedOptionId); + return visibleType == VisibleType.ALWAYS || selectedOptionIds.contains(onSelectedOption.getId()); } - public boolean containsQuestionId(long questionId) { - return questionIds.stream() - .anyMatch(sectionQuestion -> sectionQuestion.hasQuestionId(questionId)); + public boolean contains(Question question) { + return questions.contains(question); + } + + public boolean isConditional() { + return visibleType == VisibleType.CONDITIONAL; } } diff --git a/backend/src/main/java/reviewme/template/domain/SectionQuestion.java b/backend/src/main/java/reviewme/template/domain/SectionQuestion.java deleted file mode 100644 index 36bed180c..000000000 --- a/backend/src/main/java/reviewme/template/domain/SectionQuestion.java +++ /dev/null @@ -1,36 +0,0 @@ -package reviewme.template.domain; - -import jakarta.persistence.Column; -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.Getter; -import lombok.NoArgsConstructor; - -@Entity -@Table(name = "section_question") -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@Getter -public class SectionQuestion { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(name = "section_id", nullable = false, insertable = false, updatable = false) - private long sectionId; - - @Column(name = "question_id", nullable = false) - private long questionId; - - public SectionQuestion(long questionId) { - this.questionId = questionId; - } - - public boolean hasQuestionId(long questionId) { - return this.questionId == questionId; - } -} diff --git a/backend/src/main/java/reviewme/template/domain/SelectionRange.java b/backend/src/main/java/reviewme/template/domain/SelectionRange.java new file mode 100644 index 000000000..a3d65c321 --- /dev/null +++ b/backend/src/main/java/reviewme/template/domain/SelectionRange.java @@ -0,0 +1,33 @@ +package reviewme.template.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +@EqualsAndHashCode +public class SelectionRange { + + @Column(name = "min_selection_count", nullable = false) + private int minSelectionCount; + + @Column(name = "max_selection_count", nullable = false) + private int maxSelectionCount; + + public SelectionRange(int minSelectionCount, int maxSelectionCount) { + if (minSelectionCount < 0 || minSelectionCount > maxSelectionCount) { + throw new InvalidSelectionRangeException(minSelectionCount, maxSelectionCount); + } + this.minSelectionCount = minSelectionCount; + this.maxSelectionCount = maxSelectionCount; + } + + public boolean isOutOfRange(int selectionCount) { + return selectionCount < minSelectionCount || selectionCount > maxSelectionCount; + } +} diff --git a/backend/src/main/java/reviewme/template/domain/Template.java b/backend/src/main/java/reviewme/template/domain/Template.java index 29b6f36d7..c576d3fab 100644 --- a/backend/src/main/java/reviewme/template/domain/Template.java +++ b/backend/src/main/java/reviewme/template/domain/Template.java @@ -15,6 +15,8 @@ import lombok.Getter; import lombok.NoArgsConstructor; +// Aggregate root +// 템플릿 하나를 만들고 이를 수정/삭제할 수 있다. 이는 하나의 애그리거트에서 일어나는 것이 자연스럽다. @Entity @Table(name = "template") @NoArgsConstructor(access = AccessLevel.PROTECTED) @@ -26,13 +28,17 @@ public class Template { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL, orphanRemoval = true) + // Template : Section은 1 : N 관계이다. Section을 여러 곳에서 재사용한다고 생각하니 머리가 아프다. 정말 재사용할 일이 있을까? 싶다. + // 마찬가지로 Section : Question도 1 : N 관계이다. Question 또한 재사용될 일이 있을까? N:M이 아닌 1:N 관계로 생각해보자 + @OneToMany(fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) @JoinColumn(name = "template_id", nullable = false, updatable = false) - private List sectionIds; + private List
sections; - public Template(List sectionIds) { - this.sectionIds = sectionIds.stream() - .map(TemplateSection::new) - .toList(); + public Template(List
sections) { + if (sections.isEmpty()) { + throw new IllegalArgumentException("섹션은 최소 한 개 이상이어야 합니다."); + } + // TODO: Max section count limit? + this.sections = sections; } } diff --git a/backend/src/main/java/reviewme/template/domain/TemplateSection.java b/backend/src/main/java/reviewme/template/domain/TemplateSection.java deleted file mode 100644 index 6d451ee80..000000000 --- a/backend/src/main/java/reviewme/template/domain/TemplateSection.java +++ /dev/null @@ -1,32 +0,0 @@ -package reviewme.template.domain; - -import jakarta.persistence.Column; -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.Getter; -import lombok.NoArgsConstructor; - -@Entity -@Table(name = "template_section") -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@Getter -public class TemplateSection { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(name = "template_id", nullable = false, insertable = false, updatable = false) - private long templateId; - - @Column(name = "section_id", nullable = false) - private long sectionId; - - public TemplateSection(long sectionId) { - this.sectionId = sectionId; - } -} diff --git a/backend/src/main/java/reviewme/question/repository/OptionGroupRepository.java b/backend/src/main/java/reviewme/template/repository/OptionGroupRepository.java similarity index 59% rename from backend/src/main/java/reviewme/question/repository/OptionGroupRepository.java rename to backend/src/main/java/reviewme/template/repository/OptionGroupRepository.java index ad2994537..c7440e6ff 100644 --- a/backend/src/main/java/reviewme/question/repository/OptionGroupRepository.java +++ b/backend/src/main/java/reviewme/template/repository/OptionGroupRepository.java @@ -1,20 +1,18 @@ -package reviewme.question.repository; +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; -import reviewme.question.domain.OptionGroup; +import reviewme.template.domain.OptionGroup; @Repository public interface OptionGroupRepository extends JpaRepository { - Optional findByQuestionId(long questionId); - @Query(""" - SELECT og FROM OptionGroup og - WHERE og.questionId IN :questionIds + SELECT og FROM Question q + JOIN q.optionGroup og + WHERE q.id = :questionId """) - List findAllByQuestionIds(List questionIds); + Optional findByQuestionId(long questionId); } diff --git a/backend/src/main/java/reviewme/question/repository/OptionItemRepository.java b/backend/src/main/java/reviewme/template/repository/OptionItemRepository.java similarity index 61% rename from backend/src/main/java/reviewme/question/repository/OptionItemRepository.java rename to backend/src/main/java/reviewme/template/repository/OptionItemRepository.java index e42274c33..ae96cb91b 100644 --- a/backend/src/main/java/reviewme/question/repository/OptionItemRepository.java +++ b/backend/src/main/java/reviewme/template/repository/OptionItemRepository.java @@ -1,15 +1,20 @@ -package reviewme.question.repository; +package reviewme.template.repository; import java.util.List; 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.OptionType; +import reviewme.template.domain.OptionItem; +import reviewme.template.domain.OptionType; @Repository public interface OptionItemRepository extends JpaRepository { + @Query(""" + SELECT o FROM OptionGroup og + JOIN og.optionItems o + WHERE og.id = :optionGroupId + """) List findAllByOptionGroupId(long optionGroupId); @Query(""" @@ -17,12 +22,4 @@ public interface OptionItemRepository extends JpaRepository { WHERE o.optionType = :optionType """) List findAllByOptionType(OptionType optionType); - - @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/template/repository/QuestionRepository.java b/backend/src/main/java/reviewme/template/repository/QuestionRepository.java new file mode 100644 index 000000000..1722f5ee5 --- /dev/null +++ b/backend/src/main/java/reviewme/template/repository/QuestionRepository.java @@ -0,0 +1,38 @@ +package reviewme.template.repository; + +import java.util.List; +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.template.domain.OptionItem; +import reviewme.template.domain.Question; + +@Repository +public interface QuestionRepository extends JpaRepository { + + @Query(""" + SELECT q.id FROM Template t + JOIN t.sections s + JOIN s.questions q + WHERE t.id = :templateId + """) + Set findAllQuestionIdByTemplateId(long templateId); + + @Query(""" + SELECT q FROM Section s + Join s.questions q + WHERE s.id = :sectionId + ORDER BY q.position + """) + List findAllBySectionIdOrderByPosition(long sectionId); + + @Query(""" + SELECT o FROM Question q + JOIN q.optionGroup og + JOIN og.optionItems o + WHERE q.id = :questionId + ORDER BY o.position + """) + List findAllOptionItemsByIdOrderByPosition(long questionId); +} diff --git a/backend/src/main/java/reviewme/template/repository/SectionRepository.java b/backend/src/main/java/reviewme/template/repository/SectionRepository.java index d40fa5a24..497a8247f 100644 --- a/backend/src/main/java/reviewme/template/repository/SectionRepository.java +++ b/backend/src/main/java/reviewme/template/repository/SectionRepository.java @@ -11,19 +11,18 @@ public interface SectionRepository extends JpaRepository { @Query(""" - SELECT s FROM Section s - JOIN TemplateSection ts - ON s.id = ts.sectionId - WHERE ts.templateId = :templateId + SELECT s FROM Template t + JOIN t.sections s + WHERE t.id = :templateId ORDER BY s.position ASC """) 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 + SELECT s FROM Template t + JOIN t.sections s + WHERE s.id = :sectionId + AND t.id = :templateId """) Optional
findByIdAndTemplateId(long sectionId, long templateId); } diff --git a/backend/src/main/java/reviewme/template/service/SectionService.java b/backend/src/main/java/reviewme/template/service/SectionService.java deleted file mode 100644 index e52347042..000000000 --- a/backend/src/main/java/reviewme/template/service/SectionService.java +++ /dev/null @@ -1,28 +0,0 @@ -package reviewme.template.service; - -import java.util.List; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import reviewme.reviewgroup.domain.ReviewGroup; -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 SectionRepository sectionRepository; - - @Transactional(readOnly = true) - public SectionNamesResponse getSectionNames(ReviewGroup reviewGroup) { - 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/main/java/reviewme/template/service/TemplateService.java b/backend/src/main/java/reviewme/template/service/TemplateService.java index a49fc5160..bf20bb030 100644 --- a/backend/src/main/java/reviewme/template/service/TemplateService.java +++ b/backend/src/main/java/reviewme/template/service/TemplateService.java @@ -3,27 +3,37 @@ 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.reviewgroup.service.ReviewGroupService; +import reviewme.template.domain.Template; +import reviewme.template.repository.TemplateRepository; +import reviewme.template.service.dto.response.SectionNamesResponse; import reviewme.template.service.dto.response.TemplateResponse; -import reviewme.template.service.mapper.TemplateMapper; +import reviewme.template.service.exception.TemplateNotFoundByReviewGroupException; @Service @RequiredArgsConstructor public class TemplateService { - private final ReviewGroupRepository reviewGroupRepository; - private final TemplateMapper templateMapper; + private final ReviewGroupService reviewGroupService; + private final TemplateRepository templateRepository; @Transactional(readOnly = true) public TemplateResponse generateReviewForm(String reviewRequestCode) { - ReviewGroup reviewGroup = findReviewGroupByRequestCodeOrThrow(reviewRequestCode); - return templateMapper.mapToTemplateResponse(reviewGroup); + ReviewGroup reviewGroup = reviewGroupService.getReviewGroupByReviewRequestCode(reviewRequestCode); + Template template = templateRepository.findById(reviewGroup.getTemplateId()) + .orElseThrow(() -> new TemplateNotFoundByReviewGroupException( + reviewGroup.getId(), reviewGroup.getTemplateId()) + ); + return TemplateResponse.of(reviewGroup, template); } - private ReviewGroup findReviewGroupByRequestCodeOrThrow(String reviewRequestCode) { - return reviewGroupRepository.findByReviewRequestCode(reviewRequestCode) - .orElseThrow(() -> new ReviewGroupNotFoundByReviewRequestCodeException(reviewRequestCode)); + @Transactional(readOnly = true) + public SectionNamesResponse getSectionNames(ReviewGroup reviewGroup) { + Template template = templateRepository.findById(reviewGroup.getTemplateId()) + .orElseThrow(() -> new TemplateNotFoundByReviewGroupException( + reviewGroup.getId(), reviewGroup.getTemplateId()) + ); + return SectionNamesResponse.from(template); } } diff --git a/backend/src/main/java/reviewme/template/service/dto/response/OptionGroupResponse.java b/backend/src/main/java/reviewme/template/service/dto/response/OptionGroupResponse.java index c46f38148..6009ea82e 100644 --- a/backend/src/main/java/reviewme/template/service/dto/response/OptionGroupResponse.java +++ b/backend/src/main/java/reviewme/template/service/dto/response/OptionGroupResponse.java @@ -1,6 +1,8 @@ package reviewme.template.service.dto.response; import java.util.List; +import reviewme.template.domain.OptionGroup; +import reviewme.template.domain.SelectionRange; public record OptionGroupResponse( long optionGroupId, @@ -8,4 +10,20 @@ public record OptionGroupResponse( int maxCount, List options ) { + + public static OptionGroupResponse from(OptionGroup optionGroup) { + List optionItemResponses = optionGroup.getOptionItems() + .stream() + .map(OptionItemResponse::from) + .toList(); + + SelectionRange selectionRange = optionGroup.getSelectionRange(); + + return new OptionGroupResponse( + optionGroup.getId(), + selectionRange.getMinSelectionCount(), + selectionRange.getMaxSelectionCount(), + optionItemResponses + ); + } } diff --git a/backend/src/main/java/reviewme/template/service/dto/response/OptionItemResponse.java b/backend/src/main/java/reviewme/template/service/dto/response/OptionItemResponse.java index b9e456989..b70dc5817 100644 --- a/backend/src/main/java/reviewme/template/service/dto/response/OptionItemResponse.java +++ b/backend/src/main/java/reviewme/template/service/dto/response/OptionItemResponse.java @@ -1,7 +1,13 @@ package reviewme.template.service.dto.response; +import reviewme.template.domain.OptionItem; + public record OptionItemResponse( long optionId, String content ) { + + public static OptionItemResponse from(OptionItem optionItem) { + return new OptionItemResponse(optionItem.getId(), optionItem.getContent()); + } } diff --git a/backend/src/main/java/reviewme/template/service/dto/response/QuestionResponse.java b/backend/src/main/java/reviewme/template/service/dto/response/QuestionResponse.java index 90d1fb45e..ca0fef14e 100644 --- a/backend/src/main/java/reviewme/template/service/dto/response/QuestionResponse.java +++ b/backend/src/main/java/reviewme/template/service/dto/response/QuestionResponse.java @@ -1,14 +1,28 @@ package reviewme.template.service.dto.response; import jakarta.annotation.Nullable; +import reviewme.template.domain.Question; +import reviewme.template.domain.QuestionType; public record QuestionResponse( long questionId, boolean required, String content, - String questionType, + QuestionType questionType, @Nullable OptionGroupResponse optionGroup, boolean hasGuideline, @Nullable String guideline ) { + + public static QuestionResponse from(Question question) { + return new QuestionResponse( + question.getId(), + question.isRequired(), + question.getContent(), + question.getQuestionType(), + question.isCheckbox() ? OptionGroupResponse.from(question.getOptionGroup()) : null, + question.hasGuideline(), + question.getGuideline() + ); + } } 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 index 6b1fae53c..04163dcc6 100644 --- a/backend/src/main/java/reviewme/template/service/dto/response/SectionNamesResponse.java +++ b/backend/src/main/java/reviewme/template/service/dto/response/SectionNamesResponse.java @@ -1,8 +1,17 @@ package reviewme.template.service.dto.response; import java.util.List; +import reviewme.template.domain.Template; public record SectionNamesResponse( List sections ) { + + public static SectionNamesResponse from(Template template) { + List sectionNames = template.getSections() + .stream() + .map(section -> new SectionNameResponse(section.getId(), section.getSectionName())) + .toList(); + return new SectionNamesResponse(sectionNames); + } } diff --git a/backend/src/main/java/reviewme/template/service/dto/response/SectionResponse.java b/backend/src/main/java/reviewme/template/service/dto/response/SectionResponse.java index 31ae9d849..4884c7a02 100644 --- a/backend/src/main/java/reviewme/template/service/dto/response/SectionResponse.java +++ b/backend/src/main/java/reviewme/template/service/dto/response/SectionResponse.java @@ -2,13 +2,31 @@ import jakarta.annotation.Nullable; import java.util.List; +import reviewme.template.domain.Section; +import reviewme.template.domain.VisibleType; public record SectionResponse( long sectionId, String sectionName, - String visible, + VisibleType visible, @Nullable Long onSelectedOptionId, String header, List questions ) { + + public static SectionResponse from(Section section) { + List questionResponses = section.getQuestions() + .stream() + .map(QuestionResponse::from) + .toList(); + + return new SectionResponse( + section.getId(), + section.getSectionName(), + section.getVisibleType(), + section.isConditional() ? section.getOnSelectedOption().getId() : null, + section.getHeader(), + questionResponses + ); + } } diff --git a/backend/src/main/java/reviewme/template/service/dto/response/TemplateResponse.java b/backend/src/main/java/reviewme/template/service/dto/response/TemplateResponse.java index 35575ca26..8826f38e4 100644 --- a/backend/src/main/java/reviewme/template/service/dto/response/TemplateResponse.java +++ b/backend/src/main/java/reviewme/template/service/dto/response/TemplateResponse.java @@ -1,6 +1,8 @@ package reviewme.template.service.dto.response; import java.util.List; +import reviewme.reviewgroup.domain.ReviewGroup; +import reviewme.template.domain.Template; public record TemplateResponse( long formId, @@ -8,4 +10,18 @@ public record TemplateResponse( String projectName, List sections ) { + + public static TemplateResponse of(ReviewGroup reviewGroup, Template template) { + List sectionResponses = template.getSections() + .stream() + .map(SectionResponse::from) + .toList(); + + return new TemplateResponse( + reviewGroup.getTemplateId(), + reviewGroup.getReviewee(), + reviewGroup.getProjectName(), + sectionResponses + ); + } } diff --git a/backend/src/main/java/reviewme/template/service/exception/QuestionInSectionNotFoundException.java b/backend/src/main/java/reviewme/template/service/exception/QuestionInSectionNotFoundException.java deleted file mode 100644 index d8dc6314b..000000000 --- a/backend/src/main/java/reviewme/template/service/exception/QuestionInSectionNotFoundException.java +++ /dev/null @@ -1,13 +0,0 @@ -package reviewme.template.service.exception; - -import lombok.extern.slf4j.Slf4j; -import reviewme.global.exception.DataInconsistencyException; - -@Slf4j -public class QuestionInSectionNotFoundException extends DataInconsistencyException { - - public QuestionInSectionNotFoundException(long sectionId, long questionId) { - super("서버 내부에 문제가 발생했습니다. 잠시 후 다시 시도해주세요."); - log.error("Question in section not found - sectionId: {}, questionId: {}", sectionId, questionId, this); - } -} diff --git a/backend/src/main/java/reviewme/template/service/mapper/TemplateMapper.java b/backend/src/main/java/reviewme/template/service/mapper/TemplateMapper.java deleted file mode 100644 index 02b6084f1..000000000 --- a/backend/src/main/java/reviewme/template/service/mapper/TemplateMapper.java +++ /dev/null @@ -1,121 +0,0 @@ -package reviewme.template.service.mapper; - -import java.util.List; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; -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.reviewgroup.domain.ReviewGroup; -import reviewme.template.domain.Section; -import reviewme.template.domain.SectionQuestion; -import reviewme.template.domain.Template; -import reviewme.template.domain.TemplateSection; -import reviewme.template.service.exception.MissingOptionItemsInOptionGroupException; -import reviewme.template.service.exception.SectionInTemplateNotFoundException; -import reviewme.template.service.exception.TemplateNotFoundByReviewGroupException; -import reviewme.template.repository.SectionRepository; -import reviewme.template.repository.TemplateRepository; -import reviewme.template.service.dto.response.OptionGroupResponse; -import reviewme.template.service.dto.response.OptionItemResponse; -import reviewme.template.service.dto.response.QuestionResponse; -import reviewme.template.service.dto.response.SectionResponse; -import reviewme.template.service.dto.response.TemplateResponse; -import reviewme.template.service.exception.QuestionInSectionNotFoundException; - -@Component -@RequiredArgsConstructor -public class TemplateMapper { - - public static final String REVIEWEE_NAME_PLACEHOLDER = "{revieweeName}"; - - private final TemplateRepository templateRepository; - private final SectionRepository sectionRepository; - private final QuestionRepository questionRepository; - private final OptionGroupRepository optionGroupRepository; - private final OptionItemRepository optionItemRepository; - - public TemplateResponse mapToTemplateResponse(ReviewGroup reviewGroup) { - Template template = templateRepository.findById(reviewGroup.getTemplateId()) - .orElseThrow(() -> new TemplateNotFoundByReviewGroupException( - reviewGroup.getId(), reviewGroup.getTemplateId() - )); - - List sectionResponses = template.getSectionIds() - .stream() - .map(this::mapToSectionResponse) - .toList(); - - return new TemplateResponse( - template.getId(), - reviewGroup.getReviewee(), - reviewGroup.getProjectName(), - sectionResponses - ); - } - - private SectionResponse mapToSectionResponse(TemplateSection templateSection) { - Section section = sectionRepository.findById(templateSection.getSectionId()) - .orElseThrow(() -> new SectionInTemplateNotFoundException( - templateSection.getTemplateId(), templateSection.getSectionId()) - ); - List questionResponses = section.getQuestionIds() - .stream() - .map(this::mapToQuestionResponse) - .toList(); - - return new SectionResponse( - section.getId(), - section.getSectionName(), - section.getVisibleType().name(), - section.getOnSelectedOptionId(), - section.getHeader(), - questionResponses - ); - } - - private QuestionResponse mapToQuestionResponse(SectionQuestion sectionQuestion) { - Question question = questionRepository.findById(sectionQuestion.getQuestionId()) - .orElseThrow(() -> new QuestionInSectionNotFoundException( - sectionQuestion.getSectionId(), sectionQuestion.getQuestionId()) - ); - OptionGroupResponse optionGroupResponse = optionGroupRepository.findByQuestionId(question.getId()) - .map(this::mapToOptionGroupResponse) - .orElse(null); - - return new QuestionResponse( - question.getId(), - question.isRequired(), - question.getContent(), - question.getQuestionType().name(), - optionGroupResponse, - question.hasGuideline(), - question.getGuideline() - ); - } - - private OptionGroupResponse mapToOptionGroupResponse(OptionGroup optionGroup) { - List optionItems = optionItemRepository.findAllByOptionGroupId(optionGroup.getId()); - if (optionItems.isEmpty()) { - throw new MissingOptionItemsInOptionGroupException(optionGroup.getId()); - } - - List optionItemResponses = optionItems.stream() - .map(this::mapToOptionItemResponse) - .toList(); - - return new OptionGroupResponse( - optionGroup.getId(), - optionGroup.getMinSelectionCount(), - optionGroup.getMaxSelectionCount(), - optionItemResponses - ); - } - - private OptionItemResponse mapToOptionItemResponse(OptionItem optionItem) { - return new OptionItemResponse(optionItem.getId(), optionItem.getContent()); - } -} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index aa0160b1f..45df6e2cb 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -37,9 +37,3 @@ cors: allowed-origins: - http://localhost - https://localhost - -request-limit: - threshold: 3 - duration: 1s - host: localhost - port: 6379 diff --git a/backend/src/main/resources/db/migration/V4__template_association.sql b/backend/src/main/resources/db/migration/V4__template_association.sql new file mode 100644 index 000000000..440768ed9 --- /dev/null +++ b/backend/src/main/resources/db/migration/V4__template_association.sql @@ -0,0 +1,19 @@ +-- 기존 Template의 중간 테이블을 사용하지 않도록 수정합니다. +-- 1:N이 됨에 따라, N 부분에 foreign key가 필요합니다. + +ALTER TABLE section ADD COLUMN template_id BIGINT; +ALTER TABLE question ADD COLUMN section_id BIGINT; +ALTER TABLE question ADD COLUMN option_group_id BIGINT; + +-- 기존 테이블의 데이터를 새로운 테이블로 이동 +UPDATE question q JOIN section_question sq ON q.id = sq.question_id SET q.section_id = sq.section_id; +UPDATE section s JOIN template_section st ON s.id = st.section_id SET s.template_id = st.template_id; +UPDATE question q JOIN option_group og ON q.id = og.question_id SET q.option_group_id = og.id; + +-- FK 관계 설정 +ALTER TABLE section ADD CONSTRAINT section_fk_template_id FOREIGN KEY (template_id) REFERENCES template (id); +ALTER TABLE section ADD CONSTRAINT section_fk_on_selected_option_id FOREIGN KEY (on_selected_option_id) REFERENCES option_item (id); +ALTER TABLE question ADD CONSTRAINT question_fk_section_id FOREIGN KEY (section_id) REFERENCES section (id); +ALTER TABLE option_item ADD CONSTRAINT option_item_fk_option_group_id FOREIGN KEY (option_group_id) REFERENCES option_group (id); + +-- 혹시 몰라서 DROP TABLE은 하지 않음 diff --git a/backend/src/main/resources/ports.yml b/backend/src/main/resources/ports.yml deleted file mode 100644 index 8b8093829..000000000 --- a/backend/src/main/resources/ports.yml +++ /dev/null @@ -1,6 +0,0 @@ -server: - port: ${SERVER_PORT} - -management: - server: - port: ${ACTUATOR_PORT} diff --git a/backend/src/test/java/reviewme/api/ApiTest.java b/backend/src/test/java/reviewme/api/ApiTest.java index 682a2ea18..8eb10c5ed 100644 --- a/backend/src/test/java/reviewme/api/ApiTest.java +++ b/backend/src/test/java/reviewme/api/ApiTest.java @@ -1,7 +1,5 @@ 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; @@ -17,11 +15,8 @@ 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; @@ -31,11 +26,15 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.web.context.WebApplicationContext; +import reviewme.auth.controller.AuthController; +import reviewme.auth.service.AuthService; import reviewme.highlight.controller.HighlightController; import reviewme.highlight.service.HighlightService; +import reviewme.member.controller.MemberController; +import reviewme.member.service.MemberService; import reviewme.review.controller.ReviewController; -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; @@ -45,7 +44,6 @@ 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({ @@ -53,7 +51,9 @@ ReviewController.class, TemplateController.class, SectionController.class, - HighlightController.class + HighlightController.class, + MemberController.class, + AuthController.class }) @ExtendWith(RestDocumentationExtension.class) public abstract class ApiTest { @@ -79,22 +79,19 @@ public abstract class ApiTest { protected ReviewGroupLookupService reviewGroupLookupService; @MockBean - protected RedisTemplate redisTemplate; - - @Mock - protected ValueOperations valueOperations; + protected ReviewSummaryService reviewSummaryService; @MockBean - protected ReviewSummaryService reviewSummaryService; + protected ReviewGatheredLookupService reviewGatheredLookupService; @MockBean - protected SectionService sectionService; + protected HighlightService highlightService; @MockBean - protected ReviewGatheredLookupService reviewGatheredLookupService; + protected MemberService memberService; @MockBean - protected HighlightService highlightService; + protected AuthService authService; @MockBean private ReviewGroupSessionResolver reviewGroupSessionResolver; @@ -111,12 +108,6 @@ 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/AuthApiTest.java b/backend/src/test/java/reviewme/api/AuthApiTest.java new file mode 100644 index 000000000..3bf6c61c9 --- /dev/null +++ b/backend/src/test/java/reviewme/api/AuthApiTest.java @@ -0,0 +1,59 @@ +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.restdocs.cookies.CookieDescriptor; +import org.springframework.restdocs.mockmvc.RestDocumentationResultHandler; +import org.springframework.restdocs.payload.FieldDescriptor; + +public class AuthApiTest extends ApiTest { + + @Test + void 깃허브로_인증한다() { + String request = """ + { + "code": "github_auth_code" + } + """; + + FieldDescriptor[] requestFieldDescriptors = { + fieldWithPath("code").description("깃허브 임시 인증 코드"), + }; + + RestDocumentationResultHandler handler = document( + "github-auth", + requestFields(requestFieldDescriptors) + ); + + givenWithSpec().log().all() + .body(request) + .when().post("/v2/auth/github") + .then().log().all() + .apply(handler) + .statusCode(200); + } + + @Test + void 로그아웃한다() { + CookieDescriptor[] cookieDescriptors = { + cookieWithName("JSESSIONID").description("세션 ID") + }; + + RestDocumentationResultHandler handler = document( + "logout", + requestCookies(cookieDescriptors) + ); + + givenWithSpec().log().all() + .cookie("JSESSIONID", "SESSION12345678") + .when().post("/v2/auth/logout") + .then().log().all() + .apply(handler) + .statusCode(204); + } +} diff --git a/backend/src/test/java/reviewme/api/MemberApiTest.java b/backend/src/test/java/reviewme/api/MemberApiTest.java new file mode 100644 index 000000000..d8b4d4191 --- /dev/null +++ b/backend/src/test/java/reviewme/api/MemberApiTest.java @@ -0,0 +1,45 @@ +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.responseFields; + +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 reviewme.member.service.dto.ProfileResponse; + +public class MemberApiTest extends ApiTest { + + @Test + void 내_프로필을_불러온다() { + BDDMockito.given(memberService.getProfile()) + .willReturn(new ProfileResponse("donghoony", "https://aru.image")); + + CookieDescriptor[] cookieDescriptors = { + cookieWithName("JSESSIONID").description("세션 ID") + }; + + FieldDescriptor[] responseFieldDescriptors = { + fieldWithPath("nickname").description("닉네임"), + fieldWithPath("profileImageUrl").description("프로필 이미지 URL") + }; + + RestDocumentationResultHandler handler = document( + "my-profile", + requestCookies(cookieDescriptors), + responseFields(responseFieldDescriptors) + ); + + givenWithSpec().log().all() + .cookie("JSESSIONID", "SESSION12345678") + .when().get("/v2/members/profile") + .then().log().all() + .apply(handler) + .statusCode(200); + } +} diff --git a/backend/src/test/java/reviewme/api/ReviewApiTest.java b/backend/src/test/java/reviewme/api/ReviewApiTest.java index 5add4cfbd..5d354a3a7 100644 --- a/backend/src/test/java/reviewme/api/ReviewApiTest.java +++ b/backend/src/test/java/reviewme/api/ReviewApiTest.java @@ -3,7 +3,6 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyLong; -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; @@ -22,7 +21,6 @@ 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.HighlightResponse; import reviewme.review.service.dto.response.gathered.RangeResponse; @@ -31,11 +29,14 @@ 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.ReceivedReviewsResponse; +import reviewme.review.service.dto.response.list.ReceivedReviewPageResponse; 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; +import reviewme.review.service.dto.response.list.ReceivedReviewPageElementResponse; +import reviewme.review.service.dto.response.list.AuthoredReviewElementResponse; +import reviewme.review.service.dto.response.list.AuthoredReviewsResponse; +import reviewme.reviewgroup.service.exception.ReviewGroupNotFoundByReviewRequestCodeException; +import reviewme.template.domain.QuestionType; class ReviewApiTest extends ApiTest { @@ -56,7 +57,7 @@ class ReviewApiTest extends ApiTest { """; @Test - void 리뷰를_등록한다() { + void 비회원이_리뷰를_등록한다() { BDDMockito.given(reviewRegisterService.registerReview(any(ReviewRegisterRequest.class))) .willReturn(1L); @@ -70,7 +71,7 @@ class ReviewApiTest extends ApiTest { }; RestDocumentationResultHandler handler = document( - "create-review", + "create-review-by-guest", requestFields(requestFieldDescriptors) ); @@ -82,6 +83,39 @@ class ReviewApiTest extends ApiTest { .statusCode(201); } + @Test + void 회원이_리뷰를_등록한다() { + BDDMockito.given(reviewRegisterService.registerReview(any(ReviewRegisterRequest.class))) + .willReturn(1L); + + CookieDescriptor[] cookieDescriptors = { + cookieWithName("JSESSIONID").description("세션 ID") + }; + + FieldDescriptor[] requestFieldDescriptors = { + fieldWithPath("reviewRequestCode").description("리뷰 요청 코드"), + + fieldWithPath("answers[]").description("답변 목록"), + fieldWithPath("answers[].questionId").description("질문 ID"), + fieldWithPath("answers[].selectedOptionIds").description("선택한 옵션 ID 목록").optional(), + fieldWithPath("answers[].text").description("서술 답변").optional() + }; + + RestDocumentationResultHandler handler = document( + "create-review-by-member", + requestCookies(cookieDescriptors), + requestFields(requestFieldDescriptors) + ); + + givenWithSpec().log().all() + .cookie("JSESSIONID", "ASVNE1VAKDNV4") + .body(request) + .when().post("/v2/reviews") + .then().log().all() + .apply(handler) + .statusCode(201); + } + @Test void 리뷰_그룹_코드가_올바르지_않은_경우_예외가_발생한다() { BDDMockito.given(reviewRegisterService.registerReview(any(ReviewRegisterRequest.class))) @@ -132,22 +166,17 @@ class ReviewApiTest extends ApiTest { fieldWithPath("sections[].sectionId").description("섹션 ID"), fieldWithPath("sections[].header").description("섹션 제목"), - fieldWithPath("sections[].questions[]").description("질문 목록"), - fieldWithPath("sections[].questions[].questionId").description("질문 ID"), - fieldWithPath("sections[].questions[].required").description("필수 여부"), - fieldWithPath("sections[].questions[].content").description("질문 내용"), - fieldWithPath("sections[].questions[].questionType").description("질문 타입"), - - fieldWithPath("sections[].questions[].optionGroup").description("옵션 그룹").optional(), - fieldWithPath("sections[].questions[].optionGroup.optionGroupId").description("옵션 그룹 ID"), - fieldWithPath("sections[].questions[].optionGroup.minCount").description("최소 선택 개수"), - fieldWithPath("sections[].questions[].optionGroup.maxCount").description("최대 선택 개수"), - - fieldWithPath("sections[].questions[].optionGroup.options[]").description("선택 항목 목록"), - fieldWithPath("sections[].questions[].optionGroup.options[].optionId").description("선택 항목 ID"), - fieldWithPath("sections[].questions[].optionGroup.options[].content").description("선택 항목 내용"), - fieldWithPath("sections[].questions[].optionGroup.options[].isChecked").description("선택 여부"), - fieldWithPath("sections[].questions[].answer").description("서술형 답변").optional(), + fieldWithPath("sections[].reviews[]").description("리뷰 목록"), + fieldWithPath("sections[].reviews[].questionId").description("질문 ID"), + fieldWithPath("sections[].reviews[].required").description("필수 여부"), + fieldWithPath("sections[].reviews[].questionContents").description("질문 내용"), + fieldWithPath("sections[].reviews[].questionType").description("질문 타입"), + + fieldWithPath("sections[].reviews[].options").description("선택 항목 목록").optional(), + fieldWithPath("sections[].reviews[].options[]").description("선택 항목 목록"), + fieldWithPath("sections[].reviews[].options[].optionId").description("선택 항목 ID"), + fieldWithPath("sections[].reviews[].options[].content").description("선택 항목 내용"), + fieldWithPath("sections[].reviews[].answer").description("서술형 답변").optional(), }; RestDocumentationResultHandler handler = document( @@ -168,13 +197,13 @@ class ReviewApiTest extends ApiTest { @Test void 자신이_받은_리뷰_목록을_조회한다() { - List receivedReviews = List.of( - new ReviewListElementResponse(1L, LocalDate.of(2024, 8, 1), "(리뷰 미리보기 1)", + List receivedReviews = List.of( + new ReceivedReviewPageElementResponse(1L, LocalDate.of(2024, 8, 1), "(리뷰 미리보기 1)", List.of(new ReviewCategoryResponse(1L, "카테고리 1"))), - new ReviewListElementResponse(2L, LocalDate.of(2024, 8, 2), "(리뷰 미리보기 2)", + new ReceivedReviewPageElementResponse(2L, LocalDate.of(2024, 8, 2), "(리뷰 미리보기 2)", List.of(new ReviewCategoryResponse(2L, "카테고리 2"))) ); - ReceivedReviewsResponse response = new ReceivedReviewsResponse( + ReceivedReviewPageResponse response = new ReceivedReviewPageResponse( "아루3", "리뷰미", 1L, true, receivedReviews); BDDMockito.given(reviewListLookupService.getReceivedReviews(anyLong(), anyInt(), any())) .willReturn(response); @@ -217,7 +246,7 @@ class ReviewApiTest extends ApiTest { .queryParam("reviewRequestCode", "hello!!") .queryParam("lastReviewId", "2") .queryParam("size", "5") - .when().get("/v2/reviews") + .when().get("/v2/reviews/received") .then().log().all() .apply(handler) .statusCode(200); @@ -315,4 +344,59 @@ class ReviewApiTest extends ApiTest { .apply(handler) .statusCode(200); } + + @Test + void 자신이_작성한_리뷰_목록을_조회한다() { + List authoredReviews = List.of( + new AuthoredReviewElementResponse(1L, "테드1", "리뷰미", LocalDate.of(2024, 8, 2), "(리뷰 미리보기 1)", + List.of(new ReviewCategoryResponse(1L, "카테고리 1"))), + new AuthoredReviewElementResponse(2L, "테드2", "리뷰미", LocalDate.of(2024, 8, 1), "(리뷰 미리보기 2)", + List.of(new ReviewCategoryResponse(2L, "카테고리 2"))) + ); + AuthoredReviewsResponse response = new AuthoredReviewsResponse(authoredReviews, 1L, true); + BDDMockito.given(reviewListLookupService.getAuthoredReviews(anyLong(), anyInt())) + .willReturn(response); + + CookieDescriptor[] cookieDescriptors = { + cookieWithName("JSESSIONID").description("세션 ID") + }; + + ParameterDescriptor[] queryParameter = { + parameterWithName("lastReviewId").description("페이지의 마지막 리뷰 ID - 기본으로 최신순 첫번째 페이지 응답"), + parameterWithName("size").description("페이지의 크기 - 기본으로 10개씩 응답") + }; + + FieldDescriptor[] responseFieldDescriptors = { + fieldWithPath("lastReviewId").description("페이지의 마지막 리뷰 ID"), + fieldWithPath("isLastPage").description("마지막 페이지 여부"), + + fieldWithPath("reviews[]").description("리뷰 목록 (생성일 기준 내림차순 정렬)"), + fieldWithPath("reviews[].reviewId").description("리뷰 ID"), + fieldWithPath("reviews[].createdAt").description("리뷰 작성 날짜"), + fieldWithPath("reviews[].contentPreview").description("리뷰 미리보기"), + fieldWithPath("reviews[].revieweeName").description("리뷰이 이름"), + fieldWithPath("reviews[].projectName").description("프로젝트명"), + + fieldWithPath("reviews[].categories[]").description("카테고리 목록"), + fieldWithPath("reviews[].categories[].optionId").description("카테고리 ID"), + fieldWithPath("reviews[].categories[].content").description("카테고리 내용") + }; + + RestDocumentationResultHandler handler = document( + "authored-review-list-with-pagination", + requestCookies(cookieDescriptors), + queryParameters(queryParameter), + responseFields(responseFieldDescriptors) + ); + + givenWithSpec().log().all() + .cookie("JSESSIONID", "ASVNE1VAKDNV4") +// .queryParam("reviewRequestCode", "hello!!") + .queryParam("lastReviewId", "2") + .queryParam("size", "5") + .when().get("/v2/reviews/authored") + .then().log().all() + .apply(handler) + .statusCode(200); + } } diff --git a/backend/src/test/java/reviewme/api/ReviewGroupApiTest.java b/backend/src/test/java/reviewme/api/ReviewGroupApiTest.java index e87cb8b5c..7de76c4b0 100644 --- a/backend/src/test/java/reviewme/api/ReviewGroupApiTest.java +++ b/backend/src/test/java/reviewme/api/ReviewGroupApiTest.java @@ -4,6 +4,7 @@ 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; import static org.springframework.restdocs.cookies.CookieDocumentation.responseCookies; import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; @@ -12,6 +13,8 @@ import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; +import java.time.LocalDate; +import java.util.List; import org.junit.jupiter.api.Test; import org.mockito.BDDMockito; import org.springframework.restdocs.cookies.CookieDescriptor; @@ -20,12 +23,14 @@ import org.springframework.restdocs.request.ParameterDescriptor; import reviewme.reviewgroup.service.dto.ReviewGroupCreationRequest; import reviewme.reviewgroup.service.dto.ReviewGroupCreationResponse; +import reviewme.reviewgroup.service.dto.ReviewGroupPageElementResponse; +import reviewme.reviewgroup.service.dto.ReviewGroupPageResponse; import reviewme.reviewgroup.service.dto.ReviewGroupResponse; class ReviewGroupApiTest extends ApiTest { @Test - void 리뷰_그룹을_생성한다() { + void 비회원용_리뷰_그룹을_생성한다() { BDDMockito.given(reviewGroupService.createReviewGroup(any(ReviewGroupCreationRequest.class))) .willReturn(new ReviewGroupCreationResponse("ABCD1234")); @@ -48,7 +53,7 @@ class ReviewGroupApiTest extends ApiTest { }; RestDocumentationResultHandler handler = document( - "review-group-create", + "guest-review-group-create", requestFields(requestFieldDescriptors), responseFields(responseFieldDescriptors) ); @@ -62,28 +67,99 @@ class ReviewGroupApiTest extends ApiTest { } @Test - void 리뷰_요청_코드로_리뷰_그룹_정보를_반환한다() { + void 회원용_리뷰_그룹을_생성한다() { + BDDMockito.given(reviewGroupService.createReviewGroup(any(ReviewGroupCreationRequest.class))) + .willReturn(new ReviewGroupCreationResponse("ABCD1234")); + + CookieDescriptor[] cookieDescriptors = { + cookieWithName("JSESSIONID").description("세션 ID") + }; + + String request = """ + { + "revieweeName": "아루", + "projectName": "리뷰미" + } + """; + + FieldDescriptor[] requestFieldDescriptors = { + fieldWithPath("revieweeName").description("리뷰이 이름"), + fieldWithPath("projectName").description("프로젝트 이름") + }; + + FieldDescriptor[] responseFieldDescriptors = { + fieldWithPath("reviewRequestCode").description("리뷰 요청 코드") + }; + + RestDocumentationResultHandler handler = document( + "member-review-group-create", + requestCookies(cookieDescriptors), + requestFields(requestFieldDescriptors), + responseFields(responseFieldDescriptors) + ); + + givenWithSpec().log().all() + .cookie("JSESSIONID", "ASVNE1VAKDNV4") + .body(request) + .when().post("/v2/groups") + .then().log().all() + .apply(handler) + .statusCode(200); + } + + @Test + void 리뷰_요청_코드로_회원이_만든_리뷰_그룹_정보를_반환한다() { BDDMockito.given(reviewGroupLookupService.getReviewGroupSummary(anyString())) - .willReturn(new ReviewGroupResponse("아루", "리뷰미")); + .willReturn(new ReviewGroupResponse(1L,"아루", "리뷰미")); ParameterDescriptor[] parameterDescriptors = { parameterWithName("reviewRequestCode").description("리뷰 요청 코드") }; FieldDescriptor[] responseFieldDescriptors = { + fieldWithPath("revieweeId").description("리뷰이 ID"), fieldWithPath("revieweeName").description("리뷰이 이름"), fieldWithPath("projectName").description("프로젝트 이름") }; RestDocumentationResultHandler handler = document( - "review-group-summary", + "member-review-group-summary", queryParameters(parameterDescriptors), responseFields(responseFieldDescriptors) ); givenWithSpec().log().all() .queryParam("reviewRequestCode", "ABCD1234") - .when().get("/v2/groups") + .when().get("/v2/groups/summary") + .then().log().all() + .apply(handler) + .statusCode(200); + } + + @Test + void 리뷰_요청_코드로_비회원이_만든_리뷰_그룹_정보를_반환한다() { + BDDMockito.given(reviewGroupLookupService.getReviewGroupSummary(anyString())) + .willReturn(new ReviewGroupResponse(null, "아루", "리뷰미")); + + ParameterDescriptor[] parameterDescriptors = { + parameterWithName("reviewRequestCode").description("리뷰 요청 코드") + }; + + FieldDescriptor[] responseFieldDescriptors = { + fieldWithPath("revieweeId").description("리뷰이 ID"), + fieldWithPath("revieweeName").description("리뷰이 이름"), + fieldWithPath("projectName").description("프로젝트 이름") + }; + + RestDocumentationResultHandler handler = document( + "guest-review-group-summary", + queryParameters(parameterDescriptors), + responseFields(responseFieldDescriptors) + ); + + givenWithSpec().log().all() + .queryParam("reviewRequestCode", "ABCD1234") + .when().get("/v2/groups/summary") .then().log().all() .apply(handler) .statusCode(200); @@ -121,4 +197,44 @@ class ReviewGroupApiTest extends ApiTest { .cookie("JSESSIONID") .statusCode(204); } + + @Test + void 회원이_생성한_프로젝트_목록을_반환한다() { + ReviewGroupPageResponse response = new ReviewGroupPageResponse(2L, true, + List.of( + new ReviewGroupPageElementResponse("이동훈", "우테코", LocalDate.of(2024, 1, 30), "WOOTECO1", 1), + new ReviewGroupPageElementResponse("아루", "리뷰미", LocalDate.of(2024, 1, 5), "ABCD1234", 2) + ) + ); + BDDMockito.given(reviewGroupLookupService.getMyReviewGroups()) + .willReturn(response); + + CookieDescriptor[] cookieDescriptors = { + cookieWithName("JSESSIONID").description("세션 ID") + }; + + FieldDescriptor[] responseFieldDescriptors = { + fieldWithPath("lastReviewGroupId").description("해당 페이지의 마지막 리뷰 그룹 ID"), + fieldWithPath("isLastPage").description("마지막 페이지 여부"), + fieldWithPath("reviewGroups[]").description("리뷰 그룹 목록 (생성일 기준 내림차순 정렬)"), + fieldWithPath("reviewGroups[].revieweeName").description("리뷰이 이름"), + fieldWithPath("reviewGroups[].projectName").description("프로젝트 이름"), + fieldWithPath("reviewGroups[].createdAt").description("생성일"), + fieldWithPath("reviewGroups[].reviewRequestCode").description("리뷰 요청 코드"), + fieldWithPath("reviewGroups[].reviewCount").description("작성된 리뷰 수") + }; + + RestDocumentationResultHandler handler = document( + "review-group-list", + responseFields(responseFieldDescriptors), + requestCookies(cookieDescriptors) + ); + + givenWithSpec().log().all() + .cookie("JSESSIONID", "ABCDEFGHI1234") + .when().get("/v2/groups") + .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 932039bac..7a323f59f 100644 --- a/backend/src/test/java/reviewme/api/TemplateApiTest.java +++ b/backend/src/test/java/reviewme/api/TemplateApiTest.java @@ -17,7 +17,7 @@ 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.reviewgroup.service.exception.ReviewGroupNotFoundByReviewRequestCodeException; import reviewme.template.service.dto.response.SectionNameResponse; import reviewme.template.service.dto.response.SectionNamesResponse; @@ -104,7 +104,7 @@ class TemplateApiTest extends ApiTest { new SectionNameResponse(1, "섹션1 이름"), new SectionNameResponse(2, "섹션2 이름") )); - BDDMockito.given(sectionService.getSectionNames(any())) + BDDMockito.given(templateService.getSectionNames(any())) .willReturn(response); CookieDescriptor[] cookieDescriptors = { diff --git a/backend/src/test/java/reviewme/api/TemplateFixture.java b/backend/src/test/java/reviewme/api/TemplateFixture.java index aba719fbb..334e46c8e 100644 --- a/backend/src/test/java/reviewme/api/TemplateFixture.java +++ b/backend/src/test/java/reviewme/api/TemplateFixture.java @@ -2,12 +2,11 @@ import java.time.LocalDate; import java.util.List; -import reviewme.question.domain.QuestionType; -import reviewme.review.service.dto.response.detail.OptionGroupAnswerResponse; import reviewme.review.service.dto.response.detail.OptionItemAnswerResponse; import reviewme.review.service.dto.response.detail.QuestionAnswerResponse; -import reviewme.review.service.dto.response.detail.SectionAnswerResponse; import reviewme.review.service.dto.response.detail.ReviewDetailResponse; +import reviewme.review.service.dto.response.detail.SectionAnswerResponse; +import reviewme.template.domain.QuestionType; import reviewme.template.domain.VisibleType; import reviewme.template.service.dto.response.OptionGroupResponse; import reviewme.template.service.dto.response.OptionItemResponse; @@ -29,14 +28,14 @@ public static TemplateResponse templateResponse() { 1, true, "프로젝트 기간 동안, 아루의 강점이 드러났던 순간을 선택해주세요.", - QuestionType.CHECKBOX.name(), + QuestionType.CHECKBOX, new OptionGroupResponse(1, 1, 2, firstSectionOptions), false, null ) ); SectionResponse firstSection = new SectionResponse( - 1, "카테고리 선택", VisibleType.ALWAYS.name(), null, "아루와 함께 한 기억을 떠올려볼게요.", firstSectionQuestions + 1, "카테고리 선택", VisibleType.ALWAYS, null, "아루와 함께 한 기억을 떠올려볼게요.", firstSectionQuestions ); // Section 2 @@ -50,7 +49,7 @@ public static TemplateResponse templateResponse() { 2, true, "커뮤니케이션, 협업 능력에서 어떤 부분이 인상 깊었는지 선택해주세요.", - QuestionType.CHECKBOX.name(), + QuestionType.CHECKBOX, new OptionGroupResponse(2, 1, 3, secondSectionOptions), false, null @@ -59,14 +58,14 @@ public static TemplateResponse templateResponse() { 3, true, "위에서 선택한 사항에 대해 조금 더 자세히 설명해주세요.", - QuestionType.TEXT.name(), + QuestionType.TEXT, null, true, "상황을 자세하게 기록할수록 아루에게 도움이 돼요. 아루 덕분에 팀이 원활한 소통을 이뤘거나, 함께 일하면서 배울 점이 있었는지 떠올려 보세요." ) ); SectionResponse secondSection = new SectionResponse( - 2, "커뮤니케이션 능력", VisibleType.ALWAYS.name(), 1L, "아루의 커뮤니케이션, 협업 능력을 평가해주세요.", secondSectionQuestions + 2, "커뮤니케이션 능력", VisibleType.ALWAYS, 1L, "아루의 커뮤니케이션, 협업 능력을 평가해주세요.", secondSectionQuestions ); return new TemplateResponse(1, "아루", "리뷰미", List.of(firstSection, secondSection)); @@ -74,36 +73,41 @@ public static TemplateResponse templateResponse() { public static ReviewDetailResponse templateAnswerResponse() { // Section 1 - List firstOptionAnswers = List.of( - new OptionItemAnswerResponse(1, "커뮤니케이션, 협업 능력 (ex: 팀원간의 원활한 정보 공유, 명확한 의사소통)", true), - new OptionItemAnswerResponse(2, "문제 해결 능력 (ex: 프로젝트 중 만난 버그/오류를 분석하고 이를 해결하는 능력)", false), - new OptionItemAnswerResponse(3, "시간 관리 능력 (ex: 일정과 마감 기한 준수, 업무의 우선 순위 분배)", false) + List optionAnswer = List.of( + new OptionItemAnswerResponse(1, "커뮤니케이션, 협업 능력 (ex: 팀원간의 원활한 정보 공유, 명확한 의사소통)"), + new OptionItemAnswerResponse(2, "문제 해결 능력 (ex: 프로젝트 중 만난 버그/오류를 분석하고 이를 해결하는 능력)"), + new OptionItemAnswerResponse(3, "시간 관리 능력 (ex: 일정과 마감 기한 준수, 업무의 우선 순위 분배)") ); - OptionGroupAnswerResponse firstOptionGroupAnswer = new OptionGroupAnswerResponse(1, 1, 2, firstOptionAnswers); QuestionAnswerResponse firstQuestionAnswer = new QuestionAnswerResponse( - 1, true, QuestionType.CHECKBOX, "프로젝트 기간 동안, 아루의 강점이 드러났던 순간을 선택해주세요.", firstOptionGroupAnswer, null - ); - SectionAnswerResponse firstSectionAnswer = new SectionAnswerResponse( - 1, "프로젝트 기간 동안, 아루의 강점이 드러났던 순간을 선택해주세요.", List.of(firstQuestionAnswer) + 1, true, QuestionType.CHECKBOX, "프로젝트 기간 동안, 아루의 강점이 드러났던 순간을 선택해주세요.", optionAnswer, null ); - // Section 2 - List secondOptionAnswers = List.of( - new OptionItemAnswerResponse(4, "반대 의견을 내더라도 듣는 사람이 기분 나쁘지 않게 이야기해요.", true), - new OptionItemAnswerResponse(5, "팀원들의 의견을 잘 모아서 회의가 매끄럽게 진행되도록 해요.", false), - new OptionItemAnswerResponse(6, "팀의 분위기를 주도해요.", true) - ); - 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) + QuestionAnswerResponse secondQuestionAnswer = new QuestionAnswerResponse(2, true, QuestionType.TEXT, "위에서 선택한 사항에 대해 조금 더 자세히 설명해주세요.", null, "나산초의 답변"); + + SectionAnswerResponse sectionAnswer = new SectionAnswerResponse( + 1, "프로젝트 기간 동안, 아루의 강점이 드러났던 순간을 선택해주세요.", List.of(firstQuestionAnswer, secondQuestionAnswer) ); return new ReviewDetailResponse( - 1, "아루", "리뷰미", LocalDate.of(2024, 8, 1), List.of(firstSectionAnswer, secondSectionAnswer) + 1, "아루", "리뷰미", LocalDate.of(2024, 8, 1), List.of(sectionAnswer) ); + + // Section 2 +// List secondOptionAnswers = List.of( +// new OptionItemAnswerResponse(4, "반대 의견을 내더라도 듣는 사람이 기분 나쁘지 않게 이야기해요."), +// new OptionItemAnswerResponse(5, "팀원들의 의견을 잘 모아서 회의가 매끄럽게 진행되도록 해요."), +// new OptionItemAnswerResponse(6, "팀의 분위기를 주도해요.") +// ); +// QuestionAnswerResponse secondQuestionAnswer = new QuestionAnswerResponse( +// 2, true, QuestionType.CHECKBOX, "커뮤니케이션, 협업 능력에서 어떤 부분이 인상 깊었는지 선택해주세요.", secondOptionAnswers, +// null +// ); +// SectionAnswerResponse secondSectionAnswer = new SectionAnswerResponse( +// 2, "커뮤니케이션, 협업 능력에서 어떤 부분이 인상 깊었는지 선택해주세요.", List.of(secondQuestionAnswer) +// ); + +// return new ReviewDetailResponse( +// 1, "아루", "리뷰미", LocalDate.of(2024, 8, 1), List.of(sectionAnswer, secondSectionAnswer) +// ); } } diff --git a/backend/src/test/java/reviewme/config/TestConfig.java b/backend/src/test/java/reviewme/config/TestConfig.java index f339dd641..e3a05bb95 100644 --- a/backend/src/test/java/reviewme/config/TestConfig.java +++ b/backend/src/test/java/reviewme/config/TestConfig.java @@ -1,7 +1,9 @@ package reviewme.config; import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.cache.CacheManager; import org.springframework.context.annotation.Bean; +import reviewme.support.CacheCleaner; import reviewme.support.DatabaseCleaner; @TestConfiguration @@ -11,4 +13,9 @@ public class TestConfig { public DatabaseCleaner databaseCleaner() { return new DatabaseCleaner(); } + + @Bean + public CacheCleaner cacheCleaner(CacheManager cacheManager) { + return new CacheCleaner(cacheManager); + } } diff --git a/backend/src/test/java/reviewme/config/CorsConfigTest.java b/backend/src/test/java/reviewme/config/cors/CorsConfigTest.java similarity index 93% rename from backend/src/test/java/reviewme/config/CorsConfigTest.java rename to backend/src/test/java/reviewme/config/cors/CorsConfigTest.java index 90af4a342..d7f20bfd2 100644 --- a/backend/src/test/java/reviewme/config/CorsConfigTest.java +++ b/backend/src/test/java/reviewme/config/cors/CorsConfigTest.java @@ -1,4 +1,4 @@ -package reviewme.config; +package reviewme.config.cors; import org.junit.jupiter.api.BeforeEach; import org.springframework.beans.factory.annotation.Autowired; @@ -32,6 +32,7 @@ void setUp() { static class TestController { @RequestMapping("/test") public void test() { + // Testing controller calls, no-op } } } diff --git a/backend/src/test/java/reviewme/config/ExternalCorsConfigTest.java b/backend/src/test/java/reviewme/config/cors/ExternalCorsConfigTest.java similarity index 98% rename from backend/src/test/java/reviewme/config/ExternalCorsConfigTest.java rename to backend/src/test/java/reviewme/config/cors/ExternalCorsConfigTest.java index 095bb1bc7..39445b70f 100644 --- a/backend/src/test/java/reviewme/config/ExternalCorsConfigTest.java +++ b/backend/src/test/java/reviewme/config/cors/ExternalCorsConfigTest.java @@ -1,4 +1,4 @@ -package reviewme.config; +package reviewme.config.cors; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; diff --git a/backend/src/test/java/reviewme/config/LocalCorsConfigTest.java b/backend/src/test/java/reviewme/config/cors/LocalCorsConfigTest.java similarity index 97% rename from backend/src/test/java/reviewme/config/LocalCorsConfigTest.java rename to backend/src/test/java/reviewme/config/cors/LocalCorsConfigTest.java index cd050b988..214de0857 100644 --- a/backend/src/test/java/reviewme/config/LocalCorsConfigTest.java +++ b/backend/src/test/java/reviewme/config/cors/LocalCorsConfigTest.java @@ -1,4 +1,4 @@ -package reviewme.config; +package reviewme.config.cors; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; diff --git a/backend/src/test/java/reviewme/fixture/OptionGroupFixture.java b/backend/src/test/java/reviewme/fixture/OptionGroupFixture.java index 259a3ebcf..faf47d813 100644 --- a/backend/src/test/java/reviewme/fixture/OptionGroupFixture.java +++ b/backend/src/test/java/reviewme/fixture/OptionGroupFixture.java @@ -1,10 +1,12 @@ package reviewme.fixture; -import reviewme.question.domain.OptionGroup; +import java.util.List; +import reviewme.template.domain.OptionGroup; +import reviewme.template.domain.OptionItem; public class OptionGroupFixture { - public static OptionGroup 선택지_그룹(long questionId) { - return new OptionGroup(questionId, 1, 2); + public static OptionGroup 선택지_그룹(List optionItems) { + return new OptionGroup(optionItems, 1, optionItems.size()); } } diff --git a/backend/src/test/java/reviewme/fixture/OptionItemFixture.java b/backend/src/test/java/reviewme/fixture/OptionItemFixture.java index 3b7e50725..5c43759e9 100644 --- a/backend/src/test/java/reviewme/fixture/OptionItemFixture.java +++ b/backend/src/test/java/reviewme/fixture/OptionItemFixture.java @@ -1,15 +1,11 @@ package reviewme.fixture; -import reviewme.question.domain.OptionItem; -import reviewme.question.domain.OptionType; +import reviewme.template.domain.OptionItem; +import reviewme.template.domain.OptionType; public class OptionItemFixture { - public static OptionItem 선택지(long optionGroupId) { - return 선택지(optionGroupId, 1); - } - - public static OptionItem 선택지(long optionGroupId, int position) { - return new OptionItem("선택지 본문", optionGroupId, position, OptionType.CATEGORY); + public static OptionItem 선택지() { + return new OptionItem("선택지 본문", 1, OptionType.CATEGORY); } } diff --git a/backend/src/test/java/reviewme/fixture/QuestionFixture.java b/backend/src/test/java/reviewme/fixture/QuestionFixture.java index f4ce28b88..a225a9517 100644 --- a/backend/src/test/java/reviewme/fixture/QuestionFixture.java +++ b/backend/src/test/java/reviewme/fixture/QuestionFixture.java @@ -1,10 +1,23 @@ package reviewme.fixture; -import reviewme.question.domain.Question; -import reviewme.question.domain.QuestionType; +import java.util.List; +import java.util.stream.IntStream; +import reviewme.template.domain.OptionGroup; +import reviewme.template.domain.OptionItem; +import reviewme.template.domain.OptionType; +import reviewme.template.domain.Question; +import reviewme.template.domain.QuestionType; public class QuestionFixture { + public static Question 선택형_질문(boolean required, int optionCount, int position) { + List optionItems = IntStream.rangeClosed(1, optionCount) + .mapToObj(i -> new OptionItem("선택지 본문", i, OptionType.CATEGORY)) + .toList(); + OptionGroup optionGroup = new OptionGroup(optionItems, 1, optionItems.size()); + return new Question(required, QuestionType.CHECKBOX, optionGroup, "본문", null, position); + } + public static Question 선택형_필수_질문() { return 선택형_필수_질문(1); } @@ -13,14 +26,6 @@ public class QuestionFixture { return new Question(true, QuestionType.CHECKBOX, "본문", null, position); } - public static Question 선택형_옵션_질문() { - return 선택형_옵션_질문(1); - } - - public static Question 선택형_옵션_질문(int position) { - return new Question(false, QuestionType.CHECKBOX, "본문", null, position); - } - public static Question 서술형_필수_질문() { return 서술형_필수_질문(1); } diff --git a/backend/src/test/java/reviewme/fixture/ReviewGroupFixture.java b/backend/src/test/java/reviewme/fixture/ReviewGroupFixture.java index caf03a6ef..d134bf3d0 100644 --- a/backend/src/test/java/reviewme/fixture/ReviewGroupFixture.java +++ b/backend/src/test/java/reviewme/fixture/ReviewGroupFixture.java @@ -4,11 +4,15 @@ public class ReviewGroupFixture { - public static ReviewGroup 리뷰_그룹() { - return 리뷰_그룹("reviewRequestCode", "groupAccessCode"); - } - public static ReviewGroup 리뷰_그룹(String reviewRequestCode, String groupAccessCode) { return new ReviewGroup("revieweeName", "projectName", reviewRequestCode, groupAccessCode, 1L); } + + public static ReviewGroup 템플릿_지정_리뷰_그룹(long templateId) { + return new ReviewGroup("reviewee", "project", "requestCode", "accessCode", templateId); + } + + public static ReviewGroup 리뷰_그룹() { + return 리뷰_그룹("reviewRequestCode", "groupAccessCode"); + } } diff --git a/backend/src/test/java/reviewme/fixture/SectionFixture.java b/backend/src/test/java/reviewme/fixture/SectionFixture.java index ce8d5d906..3c7cc44e3 100644 --- a/backend/src/test/java/reviewme/fixture/SectionFixture.java +++ b/backend/src/test/java/reviewme/fixture/SectionFixture.java @@ -1,24 +1,26 @@ package reviewme.fixture; import java.util.List; +import reviewme.template.domain.OptionItem; +import reviewme.template.domain.Question; import reviewme.template.domain.Section; import reviewme.template.domain.VisibleType; public class SectionFixture { - public static Section 항상_보이는_섹션(List questionIds) { - return 항상_보이는_섹션(questionIds, 1); + public static Section 항상_보이는_섹션(List questions) { + return 항상_보이는_섹션(questions, 1); } - public static Section 항상_보이는_섹션(List questionIds, int position) { - return new Section(VisibleType.ALWAYS, questionIds, null, "섹션명", "머릿말", position); + public static Section 항상_보이는_섹션(List questions, int position) { + return new Section(VisibleType.ALWAYS, questions, null, "섹션명", "머릿말", position); } - public static Section 조건부로_보이는_섹션(List questionIds, long onSelectedOptionId) { - return 조건부로_보이는_섹션(questionIds, onSelectedOptionId, 1); + public static Section 조건부로_보이는_섹션(List questions, OptionItem onSelectedOption) { + return 조건부로_보이는_섹션(questions, onSelectedOption, 1); } - public static Section 조건부로_보이는_섹션(List questionIds, long onSelectedOptionId, int position) { - return new Section(VisibleType.CONDITIONAL, questionIds, onSelectedOptionId, "섹션명", "머릿말", position); + public static Section 조건부로_보이는_섹션(List questions, OptionItem onSelectedOption, int position) { + return new Section(VisibleType.CONDITIONAL, questions, onSelectedOption, "섹션명", "머릿말", position); } } diff --git a/backend/src/test/java/reviewme/fixture/TemplateFixture.java b/backend/src/test/java/reviewme/fixture/TemplateFixture.java deleted file mode 100644 index 44826daee..000000000 --- a/backend/src/test/java/reviewme/fixture/TemplateFixture.java +++ /dev/null @@ -1,11 +0,0 @@ -package reviewme.fixture; - -import java.util.List; -import reviewme.template.domain.Template; - -public class TemplateFixture { - - public static Template 템플릿(List sectionIds) { - return new Template(sectionIds); - } -} diff --git a/backend/src/test/java/reviewme/global/RequestLimitInterceptorTest.java b/backend/src/test/java/reviewme/global/RequestLimitInterceptorTest.java deleted file mode 100644 index 998639691..000000000 --- a/backend/src/test/java/reviewme/global/RequestLimitInterceptorTest.java +++ /dev/null @@ -1,78 +0,0 @@ -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/java/reviewme/highlight/domain/HighlightedLinesTest.java b/backend/src/test/java/reviewme/highlight/domain/HighlightedLinesTest.java index 53d81c209..d3e4a443c 100644 --- a/backend/src/test/java/reviewme/highlight/domain/HighlightedLinesTest.java +++ b/backend/src/test/java/reviewme/highlight/domain/HighlightedLinesTest.java @@ -84,4 +84,21 @@ class HighlightedLinesTest { assertThatCode(() -> highlightedLines.addRange(invalidLineIndex, 0, 1)) .isInstanceOf(InvalidHighlightLineIndexException.class); } + + @Test + void 하이라이트가_존재하는_부분만_엔티티로_변환한다() { + // given + HighlightedLines lines = new HighlightedLines("0\n11\n222"); + lines.addRange(0, 0, 0); + lines.addRange(2, 2, 2); + + // when + List highlights = lines.toHighlights(1L); + + // then + assertThat(highlights).containsExactly( + new Highlight(1L, 0, new HighlightRange(0, 0)), + new Highlight(1L, 2, new HighlightRange(2, 2)) + ); + } } diff --git a/backend/src/test/java/reviewme/highlight/repository/HighlightRepositoryTest.java b/backend/src/test/java/reviewme/highlight/repository/HighlightRepositoryTest.java index 40b584f47..528ccbfff 100644 --- a/backend/src/test/java/reviewme/highlight/repository/HighlightRepositoryTest.java +++ b/backend/src/test/java/reviewme/highlight/repository/HighlightRepositoryTest.java @@ -2,13 +2,21 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; +import static reviewme.fixture.ReviewGroupFixture.리뷰_그룹; +import java.util.ArrayList; 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; +import reviewme.review.domain.Answer; +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; @DataJpaTest class HighlightRepositoryTest { @@ -16,6 +24,30 @@ class HighlightRepositoryTest { @Autowired private HighlightRepository highlightRepository; + @Autowired + private ReviewRepository reviewRepository; + + @Autowired + private ReviewGroupRepository reviewGroupRepository; + + @Test + void 한_번에_여러_하이라이트를_벌크_삽입한다() { + // given + List highlights = List.of( + new Highlight(1L, 1, new HighlightRange(1, 2)), + new Highlight(1L, 1, new HighlightRange(3, 5)) + ); + + // when + highlightRepository.saveAll(highlights); + + // then + List actual = highlightRepository.findAllByAnswerIdsOrderedAsc(List.of(1L)); + assertThat(actual) + .extracting(Highlight::getHighlightRange) + .containsExactly(new HighlightRange(1, 2), new HighlightRange(3, 5)); + } + @Test void 하이라이트를_줄번호_시작_인덱스_순서대로_정렬해서_가져온다() { // given @@ -44,4 +76,43 @@ class HighlightRepositoryTest { .containsExactly(1, 4, 2, 6, 3) ); } + + @Test + void 그룹_아이디와_질문_아이디로_하이라이트를_삭제한다() { + // given + ReviewGroup reviewGroup1 = reviewGroupRepository.save(리뷰_그룹()); + ReviewGroup reviewGroup2 = reviewGroupRepository.save(리뷰_그룹()); + + List answers1 = List.of( + new TextAnswer(1L, "A1"), + new TextAnswer(2L, "A2"), + new TextAnswer(3L, "A3") + ); + List answers2 = List.of( + new TextAnswer(1L, "B1"), + new TextAnswer(2L, "B2"), + new TextAnswer(3L, "B3") + ); + reviewRepository.save(new Review(1L, reviewGroup1.getId(), answers1)); + reviewRepository.save(new Review(2L, reviewGroup2.getId(), answers2)); + + List answerIds = new ArrayList<>(); + answerIds.addAll(answers1.stream().map(Answer::getId).toList()); + answerIds.addAll(answers2.stream().map(Answer::getId).toList()); + + HighlightRange range = new HighlightRange(0, 1); + answerIds.stream() + .map(answerId -> new Highlight(answerId, 0, range)) + .forEach(highlightRepository::save); + + // when + highlightRepository.deleteByReviewGroupIdAndQuestionId(reviewGroup1.getId(), 1L); + + // then + List actual = highlightRepository.findAllByAnswerIdsOrderedAsc(answerIds); + assertAll( + () -> assertThat(actual).hasSize(5), + () -> assertThat(actual).extracting(Highlight::getAnswerId).doesNotContain(answers1.get(0).getId()) + ); + } } diff --git a/backend/src/test/java/reviewme/highlight/service/HighlightServiceTest.java b/backend/src/test/java/reviewme/highlight/service/HighlightServiceTest.java index 32eed36b8..0a3ff0c4e 100644 --- a/backend/src/test/java/reviewme/highlight/service/HighlightServiceTest.java +++ b/backend/src/test/java/reviewme/highlight/service/HighlightServiceTest.java @@ -5,7 +5,6 @@ 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; @@ -17,14 +16,15 @@ 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.domain.ReviewGroup; import reviewme.reviewgroup.repository.ReviewGroupRepository; import reviewme.support.ServiceTest; -import reviewme.template.repository.SectionRepository; +import reviewme.template.domain.Question; +import reviewme.template.domain.Section; +import reviewme.template.domain.Template; import reviewme.template.repository.TemplateRepository; @ServiceTest @@ -42,33 +42,26 @@ class HighlightServiceTest { @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"; - ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹(reviewRequestCode, "groupAccessCode")); - - 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))); + Question question = 서술형_필수_질문(); + Section section = 항상_보이는_섹션(List.of(question)); + Template template = templateRepository.save(new Template(List.of(section))); + ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹()); + + TextAnswer textAnswer1 = new TextAnswer(question.getId(), "text answer1"); + TextAnswer textAnswer2 = new TextAnswer(question.getId(), "text answer2"); + reviewRepository.save(new Review(template.getId(), 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(textAnswer2.getId(), List.of(lineRequest)); - HighlightsRequest highlightsRequest = new HighlightsRequest(questionId, List.of(highlightRequest1)); + HighlightsRequest highlightsRequest = new HighlightsRequest(question.getId(), List.of(highlightRequest1)); // when highlightService.editHighlight(highlightsRequest, reviewGroup); @@ -80,15 +73,13 @@ class HighlightServiceTest { @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")); - + Question question = 서술형_필수_질문(); + Section section = 항상_보이는_섹션(List.of(question)); + Template template = templateRepository.save(new Template(List.of(section))); + ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹()); - TextAnswer textAnswer = new TextAnswer(questionId, "text answer1"); - Review review = reviewRepository.save(new Review(templateId, reviewGroup.getId(), List.of(textAnswer))); + TextAnswer textAnswer = new TextAnswer(question.getId(), "text answer1"); + reviewRepository.save(new Review(template.getId(), reviewGroup.getId(), List.of(textAnswer))); highlightRepository.save(new Highlight(1, 1, new HighlightRange(1, 1))); int startIndex = 2; @@ -96,34 +87,33 @@ class HighlightServiceTest { HighlightIndexRangeRequest indexRangeRequest = new HighlightIndexRangeRequest(startIndex, endIndex); 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)); + HighlightsRequest highlightsRequest = new HighlightsRequest(question.getId(), List.of(highlightRequest)); // when highlightService.editHighlight(highlightsRequest, reviewGroup); // then - List highlights = highlightRepository.findAll(); + List highlights = highlightRepository.findAllByAnswerIdsOrderedAsc(List.of(textAnswer.getId())); + Highlight actual = highlights.get(0); assertAll( - () -> assertThat(highlights.get(0).getAnswerId()).isEqualTo(textAnswer.getId()), - () -> assertThat(highlights.get(0).getHighlightRange()).isEqualTo( - new HighlightRange(startIndex, endIndex)) + () -> assertThat(actual.getAnswerId()).isEqualTo(textAnswer.getId()), + () -> assertThat(actual.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))); + Question question = 서술형_필수_질문(); + Section section = 항상_보이는_섹션(List.of(question)); + Template template = templateRepository.save(new Template(List.of(section))); + ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹()); + + TextAnswer textAnswer = new TextAnswer(question.getId(), "text answer1"); + reviewRepository.save(new Review(template.getId(), 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()); + HighlightsRequest highlightsRequest = new HighlightsRequest(question.getId(), List.of()); // when highlightService.editHighlight(highlightsRequest, reviewGroup); diff --git a/backend/src/test/java/reviewme/highlight/service/mapper/HighlightMapperTest.java b/backend/src/test/java/reviewme/highlight/service/mapper/HighlightMapperTest.java index 14a6639f9..a8954cb1f 100644 --- a/backend/src/test/java/reviewme/highlight/service/mapper/HighlightMapperTest.java +++ b/backend/src/test/java/reviewme/highlight/service/mapper/HighlightMapperTest.java @@ -5,7 +5,6 @@ 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; @@ -17,13 +16,14 @@ 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.domain.Question; +import reviewme.template.domain.Section; +import reviewme.template.domain.Template; import reviewme.template.repository.TemplateRepository; @ServiceTest @@ -41,28 +41,20 @@ class HighlightMapperTest { @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(); + Question question = 서술형_필수_질문(); + Section section = 항상_보이는_섹션(List.of(question)); + Template template = templateRepository.save(new Template(List.of(section))); + long reviewGroupId = reviewGroupRepository.save(리뷰_그룹()).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))); + TextAnswer textAnswer1 = new TextAnswer(question.getId(), "text answer1"); + TextAnswer textAnswer2 = new TextAnswer(question.getId(), "text answer2"); + reviewRepository.save(new Review(template.getId(), reviewGroupId, List.of(textAnswer1, textAnswer2))); highlightRepository.save(new Highlight(1, 1, new HighlightRange(1, 1))); @@ -74,8 +66,10 @@ class HighlightMapperTest { 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)); + HighlightsRequest highlightsRequest = new HighlightsRequest( + question.getId(), + List.of(highlightRequest1, highlightRequest2) + ); // when List highlights = highlightMapper.mapToHighlights(highlightsRequest); diff --git a/backend/src/test/java/reviewme/highlight/service/validator/HighlightValidatorTest.java b/backend/src/test/java/reviewme/highlight/service/validator/HighlightValidatorTest.java deleted file mode 100644 index 84bf793d2..000000000 --- a/backend/src/test/java/reviewme/highlight/service/validator/HighlightValidatorTest.java +++ /dev/null @@ -1,109 +0,0 @@ -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.highlight.service.dto.HighlightRequest; -import reviewme.highlight.service.dto.HighlightsRequest; -import reviewme.highlight.service.exception.SubmittedAnswerAndProvidedAnswerMismatchException; -import reviewme.question.repository.QuestionRepository; -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.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가_하이라이트의_질문_id에_해당하는_답변이_아니면_예외를_발생한다() { - // given - 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)) - .isInstanceOf(SubmittedAnswerAndProvidedAnswerMismatchException.class); - } - - @Test - void 하이라이트의_답변_id가_리뷰_그룹에_달린_답변이_아니면_예외를_발생한다() { - // given - long questionId = questionRepository.save(서술형_필수_질문()).getId(); - Section section = sectionRepository.save(항상_보이는_섹션(List.of(questionId))); - Template template = templateRepository.save(템플릿(List.of(section.getId()))); - - 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( - 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)) - .isInstanceOf(SubmittedAnswerAndProvidedAnswerMismatchException.class); - } - - @Test - void 하이라이트의_질문_id가_리뷰_그룹의_템플릿에_속한_질문이_아니면_예외를_발생한다() { - // given - 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()))); - - 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)) - .isInstanceOf(SubmittedAnswerAndProvidedAnswerMismatchException.class); - } -} diff --git a/backend/src/test/java/reviewme/question/repository/OptionGroupRepositoryTest.java b/backend/src/test/java/reviewme/question/repository/OptionGroupRepositoryTest.java deleted file mode 100644 index 1bc3ea107..000000000 --- a/backend/src/test/java/reviewme/question/repository/OptionGroupRepositoryTest.java +++ /dev/null @@ -1,46 +0,0 @@ -package reviewme.question.repository; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.*; -import static reviewme.fixture.OptionGroupFixture.선택지_그룹; -import static reviewme.fixture.QuestionFixture.선택형_필수_질문; - -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.question.domain.OptionGroup; -import reviewme.question.domain.Question; - -@DataJpaTest -class OptionGroupRepositoryTest { - - @Autowired - private OptionGroupRepository optionGroupRepository; - - @Autowired - private QuestionRepository questionRepository; - - @Test - void 질문_아이디_그룹에_포함되는_모든_옵션_그룹을_불러온다() { - // given - Question question1 = questionRepository.save(선택형_필수_질문()); - Question question2 = questionRepository.save(선택형_필수_질문()); - Question question3 = questionRepository.save(선택형_필수_질문()); - - OptionGroup optionGroup1 = optionGroupRepository.save(선택지_그룹(question1.getId())); - OptionGroup optionGroup2 = optionGroupRepository.save(선택지_그룹(question1.getId())); - OptionGroup optionGroup3 = optionGroupRepository.save(선택지_그룹(question2.getId())); - OptionGroup optionGroup4 = optionGroupRepository.save(선택지_그룹(question2.getId())); - OptionGroup optionGroup5 = optionGroupRepository.save(선택지_그룹(question3.getId())); - OptionGroup optionGroup6 = optionGroupRepository.save(선택지_그룹(question3.getId())); - - - // when - List actual = optionGroupRepository.findAllByQuestionIds( - List.of(question1.getId(), question2.getId())); - - // then - assertThat(actual).containsExactlyInAnyOrder(optionGroup1, optionGroup2, optionGroup3, optionGroup4); - } -} diff --git a/backend/src/test/java/reviewme/question/repository/OptionItemRepositoryTest.java b/backend/src/test/java/reviewme/question/repository/OptionItemRepositoryTest.java deleted file mode 100644 index 5aebbf06b..000000000 --- a/backend/src/test/java/reviewme/question/repository/OptionItemRepositoryTest.java +++ /dev/null @@ -1,67 +0,0 @@ -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 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.question.domain.OptionGroup; -import reviewme.question.domain.OptionItem; -import reviewme.question.domain.OptionType; -import reviewme.question.domain.Question; - -@DataJpaTest -class OptionItemRepositoryTest { - - @Autowired - private OptionItemRepository optionItemRepository; - - @Autowired - private OptionGroupRepository optionGroupRepository; - - @Autowired - private QuestionRepository questionRepository; - - @Test - void 옵션_타입에_해당하는_모든_옵션_아이템을_불러온다() { - // given - Question question = questionRepository.save(선택형_필수_질문()); - OptionGroup optionGroup = optionGroupRepository.save(선택지_그룹(question.getId())); - - OptionItem optionItem1 = optionItemRepository.save(선택지(optionGroup.getId())); - OptionItem optionItem2 = optionItemRepository.save(선택지(optionGroup.getId())); - - // when - List actual = optionItemRepository.findAllByOptionType(OptionType.CATEGORY); - - // then - assertThat(actual).containsExactlyInAnyOrder(optionItem1, optionItem2); - } - - @Test - void 질문_아이디_그룹에_포함되는_모든_옵션_아이템을_불러온다() { - // given - Question question1 = questionRepository.save(선택형_필수_질문()); - Question question2 = questionRepository.save(선택형_필수_질문()); - Question question3 = questionRepository.save(선택형_필수_질문()); - OptionGroup optionGroup1 = optionGroupRepository.save(선택지_그룹(question1.getId())); - OptionGroup optionGroup2 = optionGroupRepository.save(선택지_그룹(question2.getId())); - OptionGroup optionGroup3 = optionGroupRepository.save(선택지_그룹(question3.getId())); - - OptionItem optionItem1 = optionItemRepository.save(선택지(optionGroup1.getId())); - OptionItem optionItem2 = optionItemRepository.save(선택지(optionGroup1.getId())); - OptionItem optionItem3 = optionItemRepository.save(선택지(optionGroup2.getId())); - OptionItem optionItem4 = optionItemRepository.save(선택지(optionGroup3.getId())); - - // when - List actual = optionItemRepository.findAllByQuestionIds( - List.of(question1.getId(), question2.getId())); - - // then - assertThat(actual).containsExactlyInAnyOrder(optionItem1, optionItem2, optionItem3); - } -} diff --git a/backend/src/test/java/reviewme/question/repository/QuestionRepositoryTest.java b/backend/src/test/java/reviewme/question/repository/QuestionRepositoryTest.java deleted file mode 100644 index da694e335..000000000 --- a/backend/src/test/java/reviewme/question/repository/QuestionRepositoryTest.java +++ /dev/null @@ -1,135 +0,0 @@ -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.템플릿; - -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.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; -import reviewme.template.repository.TemplateRepository; - -@DataJpaTest -class QuestionRepositoryTest { - - @Autowired - private QuestionRepository questionRepository; - - @Autowired - private SectionRepository sectionRepository; - - @Autowired - private TemplateRepository templateRepository; - - @Autowired - private ReviewGroupRepository reviewGroupRepository; - - @Autowired - private OptionGroupRepository optionGroupRepository; - - @Autowired - private OptionItemRepository optionItemRepository; - - @Test - void 템플릿_아이디로_질문_목록_아이디를_모두_가져온다() { - // given - Question question1 = questionRepository.save(서술형_필수_질문(1)); - Question question2 = questionRepository.save(서술형_필수_질문(2)); - Question question3 = questionRepository.save(서술형_필수_질문(1)); - Question question4 = questionRepository.save(서술형_필수_질문(2)); - - List sectionQuestion1 = List.of(question1.getId(), question2.getId()); - List sectionQuestion2 = List.of(question3.getId(), question4.getId()); - Section section1 = sectionRepository.save(항상_보이는_섹션(sectionQuestion1)); - sectionRepository.save(항상_보이는_섹션(sectionQuestion2)); - List sectionIds = List.of(section1.getId()); - Template template = templateRepository.save(템플릿(sectionIds)); - - // when - Set actual = questionRepository.findAllQuestionIdByTemplateId(template.getId()); - - // then - assertThat(actual).containsExactlyInAnyOrder(question1.getId(), question2.getId()); - } - - @Test - void 템플릿_아이디로_질문_목록을_모두_가져온다() { - // given - Question question1 = questionRepository.save(서술형_필수_질문(1)); - Question question2 = questionRepository.save(서술형_필수_질문(2)); - Question question3 = questionRepository.save(서술형_필수_질문(1)); - Question question4 = questionRepository.save(서술형_필수_질문(2)); - - List sectionQuestion1 = List.of(question1.getId(), question2.getId()); - List sectionQuestion2 = List.of(question3.getId(), question4.getId()); - Section section1 = sectionRepository.save(항상_보이는_섹션(sectionQuestion1)); - sectionRepository.save(항상_보이는_섹션(sectionQuestion2)); - List sectionIds = List.of(section1.getId()); - Template template = templateRepository.save(템플릿(sectionIds)); - - // when - List actual = questionRepository.findAllByTemplatedId(template.getId()); - - // 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 e13ce1427..db0873650 100644 --- a/backend/src/test/java/reviewme/review/repository/AnswerRepositoryTest.java +++ b/backend/src/test/java/reviewme/review/repository/AnswerRepositoryTest.java @@ -1,27 +1,15 @@ 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.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 { @@ -29,43 +17,27 @@ class AnswerRepositoryTest { @Autowired private AnswerRepository answerRepository; - @Autowired - private QuestionRepository questionRepository; - - @Autowired - private SectionRepository sectionRepository; - - @Autowired - 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))); + long reviewGroupId = 1L; + long templateId = 1L; + + TextAnswer answer1 = new TextAnswer(1L, "답1".repeat(20)); + TextAnswer answer2 = new TextAnswer(2L, "답2".repeat(20)); + TextAnswer answer3 = new TextAnswer(2L, "답3".repeat(20)); + TextAnswer answer4 = new TextAnswer(3L, "답4".repeat(20)); + reviewRepository.save(new Review(templateId, reviewGroupId, List.of(answer1))); + reviewRepository.save(new Review(templateId, reviewGroupId, List.of(answer2))); + reviewRepository.save(new Review(templateId, reviewGroupId, List.of(answer3))); // when List actual = answerRepository.findReceivedAnswersByQuestionIds( - reviewGroup.getId(), List.of(question1.getId(), question2.getId()), 2); + reviewGroupId, List.of(1L, 2L), 2 + ); // then assertThat(actual).containsExactly(answer3, answer2); @@ -74,13 +46,13 @@ class AnswerRepositoryTest { @Test void 리뷰_그룹_id로_리뷰들을_찾아_id를_반환한다() { // given - ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹()); + long reviewGroupId = 1L; 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))); + Review review = reviewRepository.save(new Review(1L, reviewGroupId, List.of(answer1, answer2))); // when - Set actual = answerRepository.findIdsByReviewGroupId(reviewGroup.getId()); + Set actual = answerRepository.findIdsByReviewGroupId(reviewGroupId); // then assertThat(actual).containsExactly(answer1.getId(), answer2.getId()); @@ -89,20 +61,18 @@ class AnswerRepositoryTest { @Test void 질문_id로_리뷰들을_찾아_id를_반환한다() { // given - 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"); + long reviewGroupId = 1L; + TextAnswer textAnswer1_Q1 = new TextAnswer(1L, "text answer1 by Q1"); + TextAnswer textAnswer2_Q1 = new TextAnswer(1L, "text answer2 by Q1"); + TextAnswer textAnswer1_Q2 = new TextAnswer(2L, "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) + new Review(1L, reviewGroupId, List.of(textAnswer1_Q1, textAnswer2_Q1)), + new Review(1L, reviewGroupId, List.of(textAnswer1_Q2) ))); // when - Set actual = answerRepository.findIdsByQuestionId(questionId1); + Set actual = answerRepository.findIdsByQuestionId(1L); // then assertThat(actual).containsExactly(textAnswer1_Q1.getId(), textAnswer2_Q1.getId()); diff --git a/backend/src/test/java/reviewme/review/repository/ReviewRepositoryTest.java b/backend/src/test/java/reviewme/review/repository/ReviewRepositoryTest.java index 2149c7ed9..3a2786702 100644 --- a/backend/src/test/java/reviewme/review/repository/ReviewRepositoryTest.java +++ b/backend/src/test/java/reviewme/review/repository/ReviewRepositoryTest.java @@ -4,7 +4,6 @@ import static reviewme.fixture.QuestionFixture.서술형_필수_질문; import static reviewme.fixture.ReviewGroupFixture.리뷰_그룹; import static reviewme.fixture.SectionFixture.항상_보이는_섹션; -import static reviewme.fixture.TemplateFixture.템플릿; import java.time.LocalDate; import java.util.List; @@ -13,14 +12,12 @@ 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.Question; -import reviewme.question.repository.QuestionRepository; import reviewme.review.domain.Review; import reviewme.reviewgroup.domain.ReviewGroup; import reviewme.reviewgroup.repository.ReviewGroupRepository; +import reviewme.template.domain.Question; import reviewme.template.domain.Section; import reviewme.template.domain.Template; -import reviewme.template.repository.SectionRepository; import reviewme.template.repository.TemplateRepository; @DataJpaTest @@ -32,22 +29,15 @@ class ReviewRepositoryTest { @Autowired private ReviewGroupRepository reviewGroupRepository; - @Autowired - private QuestionRepository questionRepository; - - @Autowired - private SectionRepository sectionRepository; - @Autowired private TemplateRepository templateRepository; @Test void 리뷰_그룹_아이디에_해당하는_모든_리뷰를_생성일_기준_내림차순으로_불러온다() { // given - Question question = questionRepository.save(서술형_필수_질문()); - Section section = sectionRepository.save(항상_보이는_섹션(List.of(question.getId()))); - Template template = templateRepository.save(템플릿(List.of(section.getId()))); - + Question question = 서술형_필수_질문(); + Section section = 항상_보이는_섹션(List.of(question)); + Template template = templateRepository.save(new Template(List.of(section))); ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹()); Review review1 = reviewRepository.save( @@ -65,10 +55,9 @@ class ReviewRepositoryTest { @Nested @DisplayName("리뷰 그룹 아이디에 해당하는 리뷰를 생성일 기준 내림차순으로 페이징하여 불러온다") class FindByReviewGroupIdWithLimit { - - private final Question question = questionRepository.save(서술형_필수_질문()); - private final Section section = sectionRepository.save(항상_보이는_섹션(List.of(question.getId()))); - private final Template template = templateRepository.save(템플릿(List.of(section.getId()))); + Question question = 서술형_필수_질문(); + Section section = 항상_보이는_섹션(List.of(question)); + Template template = templateRepository.save(new Template(List.of(section))); private final ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹()); private final Review review1 = reviewRepository.save( @@ -160,10 +149,9 @@ class FindByReviewGroupIdWithLimit { @Nested @DisplayName("주어진 리뷰보다 오래된 리뷰가 있는지 검사한다") class ExistsOlderReviewInReviewGroup { - - Question question = questionRepository.save(서술형_필수_질문()); - Section section = sectionRepository.save(항상_보이는_섹션(List.of(question.getId()))); - Template template = templateRepository.save(템플릿(List.of(section.getId()))); + Question question = 서술형_필수_질문(); + Section section = 항상_보이는_섹션(List.of(question)); + Template template = templateRepository.save(new Template(List.of(section))); ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹()); diff --git a/backend/src/test/java/reviewme/review/service/ReviewDetailLookupServiceTest.java b/backend/src/test/java/reviewme/review/service/ReviewDetailLookupServiceTest.java index ab296d796..a3bafe23a 100644 --- a/backend/src/test/java/reviewme/review/service/ReviewDetailLookupServiceTest.java +++ b/backend/src/test/java/reviewme/review/service/ReviewDetailLookupServiceTest.java @@ -7,22 +7,14 @@ import static reviewme.fixture.OptionItemFixture.선택지; 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.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.Question; -import reviewme.question.repository.OptionGroupRepository; -import reviewme.question.repository.OptionItemRepository; -import reviewme.question.repository.QuestionRepository; import reviewme.review.domain.Answer; import reviewme.review.domain.CheckboxAnswer; import reviewme.review.domain.Review; @@ -35,9 +27,12 @@ import reviewme.reviewgroup.domain.ReviewGroup; import reviewme.reviewgroup.repository.ReviewGroupRepository; import reviewme.support.ServiceTest; +import reviewme.template.domain.OptionGroup; +import reviewme.template.domain.OptionItem; +import reviewme.template.domain.Question; +import reviewme.template.domain.QuestionType; import reviewme.template.domain.Section; import reviewme.template.domain.Template; -import reviewme.template.repository.SectionRepository; import reviewme.template.repository.TemplateRepository; @ServiceTest @@ -52,30 +47,14 @@ class ReviewDetailLookupServiceTest { @Autowired private ReviewRepository reviewRepository; - @Autowired - private SectionRepository sectionRepository; - - @Autowired - private QuestionRepository questionRepository; - - @Autowired - private OptionGroupRepository optionGroupRepository; - - @Autowired - private OptionItemRepository optionItemRepository; - @Autowired private TemplateRepository templateRepository; @Test void 리뷰_그룹에_해당하지_않는_리뷰를_조회할_경우_예외가_발생한다() { // given - String reviewRequestCode1 = "sancho"; - String groupAccessCode1 = "kirby"; - String reviewRequestCode2 = "aruru"; - String groupAccessCode2 = "tedChang"; - ReviewGroup reviewGroup1 = reviewGroupRepository.save(리뷰_그룹(reviewRequestCode1, groupAccessCode1)); - ReviewGroup reviewGroup2 = reviewGroupRepository.save(리뷰_그룹(reviewRequestCode2, groupAccessCode2)); + ReviewGroup reviewGroup1 = reviewGroupRepository.save(리뷰_그룹()); + ReviewGroup reviewGroup2 = reviewGroupRepository.save(리뷰_그룹()); Review review1 = reviewRepository.save(new Review(0, reviewGroup1.getId(), List.of())); Review review2 = reviewRepository.save(new Review(0, reviewGroup2.getId(), List.of())); @@ -92,21 +71,19 @@ class ReviewDetailLookupServiceTest { @Test void 사용자가_작성한_리뷰를_확인한다() { // given - 리뷰 그룹 저장 - String reviewRequestCode = "1111"; - String groupAccessCode = "2222"; - ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹(reviewRequestCode, groupAccessCode)); + ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹()); // given - 질문 저장 - Question question1 = questionRepository.save(선택형_필수_질문()); - Question question2 = questionRepository.save(서술형_필수_질문()); - OptionGroup optionGroup = optionGroupRepository.save(선택지_그룹(question1.getId())); - OptionItem optionItem1 = optionItemRepository.save(선택지(optionGroup.getId(), 1)); - OptionItem optionItem2 = optionItemRepository.save(선택지(optionGroup.getId(), 2)); + OptionItem optionItem1 = 선택지(); + OptionItem optionItem2 = 선택지(); + OptionGroup optionGroup = 선택지_그룹(List.of(optionItem1, optionItem2)); + Question question1 = new Question(true, QuestionType.CHECKBOX, optionGroup, "질문1", "설명1", 1); + Question question2 = 서술형_필수_질문(); // 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()))); + Section section1 = 항상_보이는_섹션(List.of(question1)); + Section section2 = 항상_보이는_섹션(List.of(question2)); + Template template = templateRepository.save(new Template(List.of(section1, section2))); // given - 리뷰 답변 저장 List answers = List.of( @@ -131,14 +108,12 @@ class NotAnsweredOptionalQuestion { @Test void 섹션에_필수가_아닌_질문만_있다면_섹션_자체를_반환하지_않는다() { // given - 리뷰 그룹 저장 - String reviewRequestCode = "sancho"; - String groupAccessCode = "kirby"; - ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹(reviewRequestCode, groupAccessCode)); + ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹()); // given - 질문, 세션, 템플릿 저장 - Question question = questionRepository.save(서술형_옵션_질문(1)); - Section section = sectionRepository.save(항상_보이는_섹션(List.of(question.getId()))); - Template template = templateRepository.save(템플릿(List.of(section.getId()))); + Question question = 서술형_옵션_질문(1); + Section section = 항상_보이는_섹션(List.of(question)); + Template template = templateRepository.save(new Template(List.of(section))); // given - 아무것도 응답하지 않은 리뷰 답변 저장 Review review = reviewRepository.save( @@ -157,15 +132,13 @@ class NotAnsweredOptionalQuestion { @Test void 섹션의_다른_질문에_응답했다면_답하지_않은_질문만_반환하지_않는다() { // given - 리뷰 그룹 저장 - String reviewRequestCode = "aruru"; - String groupAccessCode = "tedChang"; - ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹(reviewRequestCode, groupAccessCode)); + ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹()); // given - 질문, 세션, 템플릿 저장 - Question question1 = questionRepository.save(서술형_옵션_질문(1)); - Question question2 = questionRepository.save(서술형_옵션_질문(2)); - Section section = sectionRepository.save(항상_보이는_섹션(List.of(question1.getId(), question2.getId()))); - Template template = templateRepository.save(템플릿(List.of(section.getId()))); + Question question1 = 서술형_옵션_질문(1); + Question question2 = 서술형_옵션_질문(2); + Section section = 항상_보이는_섹션(List.of(question1, question2)); + Template template = templateRepository.save(new Template(List.of(section))); // given - 질문 하나에만 응답한 리뷰 답변 저장 TextAnswer textAnswer = new TextAnswer(question1.getId(), "답변".repeat(20)); @@ -182,7 +155,7 @@ class NotAnsweredOptionalQuestion { .extracting(SectionAnswerResponse::sectionId) .containsExactly(section.getId()), () -> assertThat(reviewDetail.sections()) - .flatExtracting(SectionAnswerResponse::questions) + .flatExtracting(SectionAnswerResponse::reviews) .extracting(QuestionAnswerResponse::questionId) .containsExactly(question1.getId()) ); diff --git a/backend/src/test/java/reviewme/review/service/ReviewGatheredLookupServiceTest.java b/backend/src/test/java/reviewme/review/service/ReviewGatheredLookupServiceTest.java index 141992950..ae89fc67c 100644 --- a/backend/src/test/java/reviewme/review/service/ReviewGatheredLookupServiceTest.java +++ b/backend/src/test/java/reviewme/review/service/ReviewGatheredLookupServiceTest.java @@ -3,14 +3,11 @@ 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.QuestionFixture.선택형_질문; import static reviewme.fixture.ReviewGroupFixture.리뷰_그룹; +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; @@ -18,14 +15,6 @@ 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.domain.QuestionType; -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; @@ -38,9 +27,13 @@ import reviewme.reviewgroup.domain.ReviewGroup; import reviewme.reviewgroup.repository.ReviewGroupRepository; import reviewme.support.ServiceTest; +import reviewme.template.domain.OptionGroup; +import reviewme.template.domain.OptionItem; +import reviewme.template.domain.OptionType; +import reviewme.template.domain.Question; +import reviewme.template.domain.QuestionType; import reviewme.template.domain.Section; import reviewme.template.domain.Template; -import reviewme.template.repository.SectionRepository; import reviewme.template.repository.TemplateRepository; @ServiceTest @@ -52,18 +45,6 @@ class ReviewGatheredLookupServiceTest { @Autowired private ReviewRepository reviewRepository; - @Autowired - private SectionRepository sectionRepository; - - @Autowired - private QuestionRepository questionRepository; - - @Autowired - private OptionGroupRepository optionGroupRepository; - - @Autowired - private OptionItemRepository optionItemRepository; - @Autowired private TemplateRepository templateRepository; @@ -74,47 +55,20 @@ class ReviewGatheredLookupServiceTest { @BeforeEach void saveReviewGroup() { - reviewGroup = reviewGroupRepository.save(리뷰_그룹("1111", "2222")); + reviewGroup = reviewGroupRepository.save(리뷰_그룹()); } @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( - reviewGroup, 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 - 템플릿 저장 + Question question1 = 서술형_필수_질문(); + Question question2 = 서술형_필수_질문(); + Section section1 = 항상_보이는_섹션(List.of(question1, question2)); + Template template = templateRepository.save(new Template(List.of(section1))); // given - 리뷰 답변 저장 TextAnswer answerAR1 = new TextAnswer(question1.getId(), "아루가 작성한 서술형 답변1"); @@ -140,14 +94,12 @@ class GatherAnswerByQuestionTest { @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 - 템플릿 저장 + Question question1 = 서술형_필수_질문(); + Question question2 = 서술형_필수_질문(); + Section section1 = 항상_보이는_섹션(List.of(question1)); + Section section2 = 항상_보이는_섹션(List.of(question2)); + Template template = templateRepository.save(new Template(List.of(section1, section2))); // given - 리뷰 답변 저장 TextAnswer answerAR1 = new TextAnswer(question1.getId(), "아루가 작성한 서술형 답변1"); @@ -170,13 +122,11 @@ class GatherAnswerByQuestionTest { @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 - 템플릿 저장 + Question question1 = 서술형_필수_질문(); + Question question2 = 서술형_필수_질문(); + Section section1 = 항상_보이는_섹션(List.of(question1, question2)); + Template template = templateRepository.save(new Template(List.of(section1))); // given - 리뷰 답변 저장 TextAnswer answerSC1 = new TextAnswer(question1.getId(), "산초가 작성한 서술형 답변1"); @@ -201,12 +151,10 @@ 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 - 템플릿 저장 + Question question1 = 서술형_필수_질문(); + Section section1 = 항상_보이는_섹션(List.of(question1)); + templateRepository.save(new Template(List.of(section1))); // when ReviewsGatheredBySectionResponse actual = reviewLookupService.getReceivedReviewsBySectionId( @@ -227,22 +175,19 @@ 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 - 템플릿 저장 + OptionItem optionItem1 = new OptionItem("짜장", 1, OptionType.CATEGORY); + OptionItem optionItem2 = new OptionItem("짬뽕", 2, OptionType.CATEGORY); + OptionGroup optionGroup = 선택지_그룹(List.of(optionItem1, optionItem2)); + Question question1 = new Question(true, QuestionType.CHECKBOX, optionGroup, "선택형 질문", null, 1); + Section section1 = 항상_보이는_섹션(List.of(question1)); + Template template = templateRepository.save(new Template(List.of(section1))); // given - 리뷰 답변 저장 CheckboxAnswer answer1 = new CheckboxAnswer(question1.getId(), List.of(optionItem1.getId())); - CheckboxAnswer answer2 = new CheckboxAnswer(question1.getId(), - List.of(optionItem1.getId(), optionItem2.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))); @@ -254,60 +199,20 @@ class GatherOptionAnswerByQuestionTest { // 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( - reviewGroup, 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)); + .containsExactlyInAnyOrder(tuple("짜장", 2L), 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)); + OptionItem optionItem1 = new OptionItem("우테코 산초", 1, OptionType.CATEGORY); + OptionItem optionItem2 = new OptionItem("제이든 산초", 2, OptionType.CATEGORY); + OptionGroup optionGroup = 선택지_그룹(List.of(optionItem1, optionItem2)); + Question question1 = new Question(false, QuestionType.CHECKBOX, optionGroup, "선택형 질문", null, 1); // given - 섹션, 템플릿 저장 - Section section1 = sectionRepository.save(항상_보이는_섹션(List.of(question1.getId()))); - Template template = templateRepository.save(템플릿(List.of(section1.getId()))); + Section section1 = 항상_보이는_섹션(List.of(question1)); + Template template = templateRepository.save(new Template(List.of(section1))); // given - 리뷰 답변 저장 CheckboxAnswer answer1 = new CheckboxAnswer(question1.getId(), List.of(optionItem1.getId())); @@ -323,30 +228,27 @@ class GatherOptionAnswerByQuestionTest { // then assertThat(actual.reviews().get(0).votes()) .extracting(VoteResponse::content, VoteResponse::count) - .containsExactlyInAnyOrder( - tuple("우테코 산초", 2L), - tuple("제이든 산초", 0L) - ); + .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())); + Question question1 = 서술형_필수_질문(); + Question question2 = 선택형_질문(true, 2, 2); + List optionItems = question2.getOptionGroup().getOptionItems(); // given - 섹션, 템플릿 저장 - Section section1 = sectionRepository.save(항상_보이는_섹션(List.of(question1.getId(), question2.getId()))); - Template template = templateRepository.save(템플릿(List.of(section1.getId()))); + Section section1 = 항상_보이는_섹션(List.of(question1, question2)); + Template template = templateRepository.save(new Template(List.of(section1))); // given - 리뷰 답변 저장 TextAnswer answer1 = new TextAnswer(question1.getId(), "아루가 작성한 서술형 답변"); - CheckboxAnswer answer2 = new CheckboxAnswer(question2.getId(), - List.of(optionItem1.getId(), optionItem2.getId())); + CheckboxAnswer answer2 = new CheckboxAnswer( + question2.getId(), + optionItems.stream().map(OptionItem::getId).toList() // check all options + ); reviewRepository.save(new Review(template.getId(), reviewGroup.getId(), List.of(answer1, answer2))); // when @@ -367,8 +269,8 @@ class GatherOptionAnswerByQuestionTest { assertThat(actual.reviews().get(1).votes()) .extracting(VoteResponse::content, VoteResponse::count) .containsExactlyInAnyOrder( - tuple(optionItem1.getContent(), 1L), - tuple(optionItem2.getContent(), 1L) + tuple(optionItems.get(0).getContent(), 1L), + tuple(optionItems.get(1).getContent(), 1L) ); assertThat(actual.reviews().get(1).answers()).isNull(); } @@ -376,15 +278,12 @@ class GatherOptionAnswerByQuestionTest { @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()); + Question question1 = 서술형_필수_질문(); + Section section1 = 항상_보이는_섹션(List.of(question1)); + Template template = templateRepository.save(new Template(List.of(section1))); + + ReviewGroup reviewGroupBE = 템플릿_지정_리뷰_그룹(template.getId()); + ReviewGroup reviewGroupFE = 템플릿_지정_리뷰_그룹(template.getId()); reviewGroupRepository.saveAll(List.of(reviewGroupFE, reviewGroupBE)); // given - 리뷰 답변 저장 @@ -404,24 +303,24 @@ class GatherOptionAnswerByQuestionTest { @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()))); + Question question1 = new Question(false, QuestionType.TEXT, "질문1", null, 3); + Question question2 = new Question(false, QuestionType.TEXT, "질문2", null, 4); + Question question3 = new Question(false, QuestionType.TEXT, "질문3", null, 1); + Question question4 = new Question(false, QuestionType.TEXT, "질문4", null, 2); + Section section1 = 항상_보이는_섹션(List.of(question1, question2, question3, question4)); + templateRepository.save(new Template(List.of(section1))); // when ReviewsGatheredBySectionResponse actual = reviewLookupService.getReceivedReviewsBySectionId( - reviewGroup, section1.getId()); + reviewGroup, section1.getId() + ); // then assertThat(actual.reviews()) .extracting(ReviewsGatheredByQuestionResponse::question) .extracting(SimpleQuestionResponse::name) - .containsExactly(question3.getContent(), question4.getContent(), - question1.getContent(), question2.getContent()); + .containsExactly( + question3.getContent(), question4.getContent(), question1.getContent(), question2.getContent() + ); } } diff --git a/backend/src/test/java/reviewme/review/service/ReviewListLookupServiceTest.java b/backend/src/test/java/reviewme/review/service/ReviewListLookupServiceTest.java index d8384afe5..0d40839be 100644 --- a/backend/src/test/java/reviewme/review/service/ReviewListLookupServiceTest.java +++ b/backend/src/test/java/reviewme/review/service/ReviewListLookupServiceTest.java @@ -2,34 +2,19 @@ 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.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.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.list.ReceivedReviewsResponse; +import reviewme.review.service.dto.response.list.ReceivedReviewPageResponse; 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 ReviewListLookupServiceTest { @@ -37,52 +22,26 @@ class ReviewListLookupServiceTest { @Autowired private ReviewListLookupService reviewListLookupService; - @Autowired - private QuestionRepository questionRepository; - @Autowired private ReviewGroupRepository reviewGroupRepository; - @Autowired - private OptionItemRepository optionItemRepository; - - @Autowired - private OptionGroupRepository optionGroupRepository; - - @Autowired - private SectionRepository sectionRepository; - - @Autowired - private TemplateRepository templateRepository; - @Autowired private ReviewRepository reviewRepository; @Test void 확인_코드에_해당하는_그룹이_존재하면_내가_받은_리뷰_목록을_반환한다() { // given - 리뷰 그룹 저장 - String reviewRequestCode = "reviewRequestCode"; - String groupAccessCode = "groupAccessCode"; - ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹(reviewRequestCode, groupAccessCode)); - - // given - 질문 저장 - Question question = questionRepository.save(선택형_필수_질문()); - OptionGroup optionGroup = optionGroupRepository.save(선택지_그룹(question.getId())); - OptionItem categoryOption = optionItemRepository.save(선택지(optionGroup.getId(), 1)); - - // given - 섹션, 템플릿 저장 - Section section = sectionRepository.save(항상_보이는_섹션(List.of(question.getId()))); - Template template = templateRepository.save(템플릿(List.of(section.getId()))); + ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹()); // given - 리뷰 답변 저장 - CheckboxAnswer categoryAnswer = new CheckboxAnswer(question.getId(), List.of(categoryOption.getId())); - Review review1 = new Review(template.getId(), reviewGroup.getId(), List.of(categoryAnswer)); - TextAnswer textAnswer = new TextAnswer(question.getId(), "텍스트형 응답"); - Review review2 = new Review(template.getId(), reviewGroup.getId(), List.of(textAnswer)); + CheckboxAnswer categoryAnswer = new CheckboxAnswer(1L, List.of(1L)); + Review review1 = new Review(1L, reviewGroup.getId(), List.of(categoryAnswer)); + TextAnswer textAnswer = new TextAnswer(1L, "텍스트형 응답"); + Review review2 = new Review(1L, reviewGroup.getId(), List.of(textAnswer)); reviewRepository.saveAll(List.of(review1, review2)); // when - ReceivedReviewsResponse response = reviewListLookupService.getReceivedReviews( + ReceivedReviewPageResponse response = reviewListLookupService.getReceivedReviews( Long.MAX_VALUE, 5, reviewGroup ); @@ -97,26 +56,17 @@ class ReviewListLookupServiceTest { @Test void 내가_받은_리뷰_목록을_페이지네이션을_적용하여_반환한다() { // given - 리뷰 그룹 저장 - String reviewRequestCode = "reviewRequestCode"; - String groupAccessCode = "groupAccessCode"; - ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹(reviewRequestCode, groupAccessCode)); - - // given - 질문 저장 - Question question = questionRepository.save(선택형_필수_질문()); - - // given - 섹션, 템플릿 저장 - Section section = sectionRepository.save(항상_보이는_섹션(List.of(question.getId()))); - Template template = templateRepository.save(템플릿(List.of(section.getId()))); + ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹()); // given - 리뷰 답변 저장 - TextAnswer textAnswer = new TextAnswer(question.getId(), "텍스트형 응답"); - Review review1 = new Review(template.getId(), reviewGroup.getId(), List.of(textAnswer)); - Review review2 = new Review(template.getId(), reviewGroup.getId(), List.of(textAnswer)); - Review review3 = new Review(template.getId(), reviewGroup.getId(), List.of(textAnswer)); + TextAnswer textAnswer = new TextAnswer(1L, "텍스트형 응답"); + Review review1 = new Review(1L, reviewGroup.getId(), List.of(textAnswer)); + Review review2 = new Review(1L, reviewGroup.getId(), List.of(textAnswer)); + Review review3 = new Review(1L, reviewGroup.getId(), List.of(textAnswer)); reviewRepository.saveAll(List.of(review1, review2, review3)); // when - ReceivedReviewsResponse response + ReceivedReviewPageResponse response = reviewListLookupService.getReceivedReviews(Long.MAX_VALUE, 2, reviewGroup); // then diff --git a/backend/src/test/java/reviewme/review/service/ReviewRegisterServiceTest.java b/backend/src/test/java/reviewme/review/service/ReviewRegisterServiceTest.java index 73ab64897..7d65c2a17 100644 --- a/backend/src/test/java/reviewme/review/service/ReviewRegisterServiceTest.java +++ b/backend/src/test/java/reviewme/review/service/ReviewRegisterServiceTest.java @@ -6,21 +6,13 @@ import static reviewme.fixture.OptionItemFixture.선택지; 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.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.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; @@ -30,9 +22,12 @@ import reviewme.reviewgroup.domain.ReviewGroup; import reviewme.reviewgroup.repository.ReviewGroupRepository; import reviewme.support.ServiceTest; +import reviewme.template.domain.OptionGroup; +import reviewme.template.domain.OptionItem; +import reviewme.template.domain.Question; +import reviewme.template.domain.QuestionType; import reviewme.template.domain.Section; import reviewme.template.domain.Template; -import reviewme.template.repository.SectionRepository; import reviewme.template.repository.TemplateRepository; @ServiceTest @@ -41,15 +36,6 @@ class ReviewRegisterServiceTest { @Autowired private ReviewRegisterService reviewRegisterService; - @Autowired - private QuestionRepository questionRepository; - - @Autowired - private OptionGroupRepository optionGroupRepository; - - @Autowired - private OptionItemRepository optionItemRepository; - @Autowired private ReviewGroupRepository reviewGroupRepository; @@ -59,38 +45,33 @@ class ReviewRegisterServiceTest { @Autowired private ReviewRepository reviewRepository; - @Autowired - private SectionRepository sectionRepository; - @Test void 요청한_내용으로_리뷰를_등록한다() { // given ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹()); - Question requiredCheckQuestion = questionRepository.save(선택형_필수_질문()); - OptionGroup requiredOptionGroup = optionGroupRepository.save(선택지_그룹(requiredCheckQuestion.getId())); - OptionItem requiredOptionItem1 = optionItemRepository.save(선택지(requiredOptionGroup.getId())); - OptionItem requiredOptionItem2 = optionItemRepository.save(선택지(requiredOptionGroup.getId())); - Section visibleSection = sectionRepository.save(항상_보이는_섹션(List.of(requiredCheckQuestion.getId()), 1)); - - Question requiredTextQuestion = questionRepository.save(서술형_필수_질문()); - Question conditionalCheckQuestion = questionRepository.save(선택형_필수_질문()); - OptionGroup conditionalOptionGroup = optionGroupRepository.save(선택지_그룹(conditionalCheckQuestion.getId())); - OptionItem conditionalOptionItem1 = optionItemRepository.save(선택지(conditionalOptionGroup.getId())); - OptionItem conditionalOptionItem2 = optionItemRepository.save(선택지(conditionalOptionGroup.getId())); - Section conditionalSection = sectionRepository.save(조건부로_보이는_섹션( - List.of(requiredCheckQuestion.getId(), requiredTextQuestion.getId(), conditionalCheckQuestion.getId()), - requiredOptionItem1.getId(), 2) + OptionItem requiredOptionItem1 = 선택지(); + OptionItem requiredOptionItem2 = 선택지(); + OptionGroup requiredOptionGroup = 선택지_그룹(List.of(requiredOptionItem1, requiredOptionItem2)); + Question requiredCheckQuestion = new Question(true, QuestionType.CHECKBOX, requiredOptionGroup, "질문", "설명", 1); + Section visibleSection = 항상_보이는_섹션(List.of(requiredCheckQuestion), 1); + + OptionItem conditionalOptionItem1 = 선택지(); + OptionItem conditionalOptionItem2 = 선택지(); + OptionGroup conditionalOptionGroup = 선택지_그룹(List.of(conditionalOptionItem1, conditionalOptionItem2)); + Question requiredTextQuestion = 서술형_필수_질문(); + Question conditionalCheckQuestion = new Question(false, QuestionType.CHECKBOX, conditionalOptionGroup, "질문", + "설명", 1); + Section conditionalSection = 조건부로_보이는_섹션( + List.of(requiredCheckQuestion, requiredTextQuestion, conditionalCheckQuestion), requiredOptionItem1, 2 ); - Question optionalTextQuestion = questionRepository.save(서술형_옵션_질문()); - Section visibleOptionalSection = sectionRepository.save(항상_보이는_섹션( - List.of(optionalTextQuestion.getId()), 3) + Question optionalTextQuestion = 서술형_옵션_질문(); + Section visibleOptionalSection = 항상_보이는_섹션(List.of(optionalTextQuestion), 3); + templateRepository.save( + new Template(List.of(visibleSection, conditionalSection, visibleOptionalSection)) ); - Template template = templateRepository.save(템플릿( - List.of(visibleSection.getId(), conditionalSection.getId(), visibleOptionalSection.getId()))); - ReviewAnswerRequest requiredCheckQuestionAnswer = new ReviewAnswerRequest( requiredCheckQuestion.getId(), List.of(requiredOptionItem1.getId()), null); ReviewAnswerRequest requiredTextQuestionAnswer = new ReviewAnswerRequest( diff --git a/backend/src/test/java/reviewme/review/service/ReviewSummaryServiceTest.java b/backend/src/test/java/reviewme/review/service/ReviewSummaryServiceTest.java index 2a2ffa7a5..c7bbd1660 100644 --- a/backend/src/test/java/reviewme/review/service/ReviewSummaryServiceTest.java +++ b/backend/src/test/java/reviewme/review/service/ReviewSummaryServiceTest.java @@ -5,22 +5,19 @@ 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.reviewgroup.domain.ReviewGroup; import reviewme.reviewgroup.repository.ReviewGroupRepository; import reviewme.support.ServiceTest; +import reviewme.template.domain.Question; import reviewme.template.domain.Section; import reviewme.template.domain.Template; -import reviewme.template.repository.SectionRepository; import reviewme.template.repository.TemplateRepository; @ServiceTest @@ -38,21 +35,15 @@ class ReviewSummaryServiceTest { @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()))); + Question question = 서술형_필수_질문(); + Section section = 항상_보이는_섹션(List.of(question)); + Template template = templateRepository.save(new Template(List.of(section))); ReviewGroup reviewGroup1 = reviewGroupRepository.save(리뷰_그룹()); - ReviewGroup reviewGroup2 = reviewGroupRepository.save(리뷰_그룹("reReCo", "groupCo")); + ReviewGroup reviewGroup2 = reviewGroupRepository.save(리뷰_그룹()); List reviews = List.of( new Review(template.getId(), reviewGroup1.getId(), List.of()), diff --git a/backend/src/test/java/reviewme/review/service/mapper/AnswerMapperFactoryTest.java b/backend/src/test/java/reviewme/review/service/mapper/AnswerMapperFactoryTest.java index 25d1b5018..f18dc74f3 100644 --- a/backend/src/test/java/reviewme/review/service/mapper/AnswerMapperFactoryTest.java +++ b/backend/src/test/java/reviewme/review/service/mapper/AnswerMapperFactoryTest.java @@ -8,9 +8,9 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.boot.test.system.CapturedOutput; import org.springframework.boot.test.system.OutputCaptureExtension; -import reviewme.question.domain.QuestionType; import reviewme.review.domain.Answer; import reviewme.review.service.dto.request.ReviewAnswerRequest; +import reviewme.template.domain.QuestionType; @ExtendWith(OutputCaptureExtension.class) class AnswerMapperFactoryTest { @@ -18,13 +18,13 @@ class AnswerMapperFactoryTest { private final AnswerMapper answerMapper = new AnswerMapper() { @Override - public boolean supports(QuestionType questionType) { - return questionType == QuestionType.CHECKBOX; + public Answer mapToAnswer(ReviewAnswerRequest answerRequest) { + return null; } @Override - public Answer mapToAnswer(ReviewAnswerRequest answerRequest) { - return null; + public boolean supports(QuestionType questionType) { + return questionType == QuestionType.CHECKBOX; } }; diff --git a/backend/src/test/java/reviewme/review/service/mapper/CheckboxAnswerMapperTest.java b/backend/src/test/java/reviewme/review/service/mapper/CheckboxAnswerMapperTest.java index eb2d96f98..c05b4553f 100644 --- a/backend/src/test/java/reviewme/review/service/mapper/CheckboxAnswerMapperTest.java +++ b/backend/src/test/java/reviewme/review/service/mapper/CheckboxAnswerMapperTest.java @@ -1,14 +1,14 @@ package reviewme.review.service.mapper; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.util.List; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; import reviewme.review.domain.CheckboxAnswer; import reviewme.review.domain.CheckboxAnswerSelectedOption; import reviewme.review.service.dto.request.ReviewAnswerRequest; -import reviewme.review.service.exception.CheckBoxAnswerIncludedTextException; class CheckboxAnswerMapperTest { @@ -28,16 +28,17 @@ class CheckboxAnswerMapperTest { .containsExactly(1L, 2L, 3L); } - @Test - void 체크박스_답변_요청에_텍스트가_포함되어_있으면_예외를_발생시킨다() { + @ParameterizedTest + @NullAndEmptySource + void 체크박스_답변이_비어있는_경우_null로_매핑한다(List selectedOptionIds) { // given - ReviewAnswerRequest request = new ReviewAnswerRequest(1L, List.of(1L, 2L, 3L), "text"); + ReviewAnswerRequest request = new ReviewAnswerRequest(1L, selectedOptionIds, null); + CheckboxAnswerMapper mapper = new CheckboxAnswerMapper(); // when - CheckboxAnswerMapper mapper = new CheckboxAnswerMapper(); + CheckboxAnswer actual = mapper.mapToAnswer(request); // then - assertThatThrownBy(() -> mapper.mapToAnswer(request)) - .isInstanceOf(CheckBoxAnswerIncludedTextException.class); + assertThat(actual).isNull(); } } 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 1e411f13e..1ce27dc25 100644 --- a/backend/src/test/java/reviewme/review/service/mapper/ReviewGatherMapperTest.java +++ b/backend/src/test/java/reviewme/review/service/mapper/ReviewGatherMapperTest.java @@ -2,22 +2,15 @@ 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 static reviewme.fixture.QuestionFixture.선택형_질문; +import static reviewme.fixture.SectionFixture.항상_보이는_섹션; 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; @@ -28,7 +21,11 @@ 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; +import reviewme.template.domain.OptionItem; +import reviewme.template.domain.Question; +import reviewme.template.domain.Section; +import reviewme.template.domain.Template; +import reviewme.template.repository.TemplateRepository; @ServiceTest class ReviewGatherMapperTest { @@ -37,16 +34,7 @@ class ReviewGatherMapperTest { private ReviewGatherMapper reviewGatherMapper; @Autowired - private SectionRepository sectionRepository; - - @Autowired - private QuestionRepository questionRepository; - - @Autowired - private OptionGroupRepository optionGroupRepository; - - @Autowired - private OptionItemRepository optionItemRepository; + private TemplateRepository templateRepository; @Autowired private ReviewRepository reviewRepository; @@ -54,23 +42,23 @@ class ReviewGatherMapperTest { @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)); + Question question1 = 서술형_옵션_질문(1); + Question question2 = 선택형_질문(false, 2, 2); + Section section = 항상_보이는_섹션(List.of(question1, question2)); + Template template = templateRepository.save(new Template(List.of(section))); + List optionItems = question2.getOptionGroup().getOptionItems(); 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))); + CheckboxAnswer checkboxAnswer1 = new CheckboxAnswer( + question2.getId(), + optionItems.stream().map(OptionItem::getId).toList() // check all options + ); + reviewRepository.save(new Review(template.getId(), 1L, List.of(textAnswer1, checkboxAnswer1))); // when ReviewsGatheredBySectionResponse actual = reviewGatherMapper.mapToReviewsGatheredBySection(Map.of( - question1, List.of(textAnswer1, textAnswer2), - question2, List.of(checkboxAnswer)), + question1, List.of(textAnswer1), + question2, List.of(checkboxAnswer1)), List.of() ); @@ -78,32 +66,30 @@ class ReviewGatherMapperTest { assertAll( () -> 질문의_수만큼_반환한다(actual, 2), () -> 질문의_내용을_반환한다(actual, question1.getContent(), question2.getContent()), - () -> 서술형_답변을_반환한다(actual, "프엔 서술형 답변", "백엔드 서술형 답변"), + () -> 서술형_답변을_반환한다(actual, "프엔 서술형 답변"), () -> 선택형_답변을_반환한다(actual, - Tuple.tuple(optionItem1.getContent(), 1L), - Tuple.tuple(optionItem2.getContent(), 1L)) + Tuple.tuple(optionItems.get(0).getContent(), 1L), + Tuple.tuple(optionItems.get(1).getContent(), 1L)) ); } @Test void 서술형_질문에_답변이_없으면_질문_정보는_반환하되_답변은_빈_배열로_반환한다() { // given - Question question1 = questionRepository.save(서술형_옵션_질문(1)); - Question question2 = questionRepository.save(서술형_옵션_질문(2)); + Question question = 서술형_옵션_질문(); + Section section = 항상_보이는_섹션(List.of(question)); + templateRepository.save(new Template(List.of(section))); // when ReviewsGatheredBySectionResponse actual = reviewGatherMapper.mapToReviewsGatheredBySection( - Map.of( - question1, List.of(), - question2, List.of() - ), + Map.of(question, List.of()), List.of() ); // then assertAll( - () -> 질문의_수만큼_반환한다(actual, 2), - () -> 질문의_내용을_반환한다(actual, question1.getContent(), question2.getContent()), + () -> 질문의_수만큼_반환한다(actual, 1), + () -> 질문의_내용을_반환한다(actual, question.getContent()), () -> assertThat(actual.reviews()) .flatExtracting(ReviewsGatheredByQuestionResponse::answers) .isEmpty() diff --git a/backend/src/test/java/reviewme/review/service/mapper/ReviewListMapperTest.java b/backend/src/test/java/reviewme/review/service/mapper/ReviewListMapperTest.java index 0cdfe0a32..88820f3f2 100644 --- a/backend/src/test/java/reviewme/review/service/mapper/ReviewListMapperTest.java +++ b/backend/src/test/java/reviewme/review/service/mapper/ReviewListMapperTest.java @@ -2,27 +2,18 @@ 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.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.dto.response.list.ReviewListElementResponse; +import reviewme.review.service.dto.response.list.ReceivedReviewPageElementResponse; 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 ReviewListMapperTest { @@ -30,18 +21,9 @@ class ReviewListMapperTest { @Autowired private ReviewListMapper reviewListMapper; - @Autowired - private QuestionRepository questionRepository; - @Autowired private ReviewGroupRepository reviewGroupRepository; - @Autowired - private SectionRepository sectionRepository; - - @Autowired - private TemplateRepository templateRepository; - @Autowired private ReviewRepository reviewRepository; @@ -50,25 +32,18 @@ class ReviewListMapperTest { // given - 리뷰 그룹 ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹()); - // given - 질문 저장 - Question question = questionRepository.save(선택형_필수_질문()); - - // given - 섹션, 템플릿 저장 - Section section = sectionRepository.save(항상_보이는_섹션(List.of(question.getId()))); - Template template = templateRepository.save(템플릿(List.of(section.getId()))); - // given - 리뷰 답변 저장 - TextAnswer textAnswer = new TextAnswer(question.getId(), "텍스트형 응답"); - Review review1 = new Review(template.getId(), reviewGroup.getId(), List.of(textAnswer)); - Review review2 = new Review(template.getId(), reviewGroup.getId(), List.of(textAnswer)); - Review review3 = new Review(template.getId(), reviewGroup.getId(), List.of(textAnswer)); - Review review4 = new Review(template.getId(), reviewGroup.getId(), List.of(textAnswer)); - Review review5 = new Review(template.getId(), reviewGroup.getId(), List.of(textAnswer)); - Review review6 = new Review(template.getId(), reviewGroup.getId(), List.of(textAnswer)); - Review review7 = new Review(template.getId(), reviewGroup.getId(), List.of(textAnswer)); - Review review8 = new Review(template.getId(), reviewGroup.getId(), List.of(textAnswer)); - Review review9 = new Review(template.getId(), reviewGroup.getId(), List.of(textAnswer)); - Review review10 = new Review(template.getId(), reviewGroup.getId(), List.of(textAnswer)); + TextAnswer textAnswer = new TextAnswer(1L, "텍스트형 응답"); + Review review1 = new Review(1L, reviewGroup.getId(), List.of(textAnswer)); + Review review2 = new Review(1L, reviewGroup.getId(), List.of(textAnswer)); + Review review3 = new Review(1L, reviewGroup.getId(), List.of(textAnswer)); + Review review4 = new Review(1L, reviewGroup.getId(), List.of(textAnswer)); + Review review5 = new Review(1L, reviewGroup.getId(), List.of(textAnswer)); + Review review6 = new Review(1L, reviewGroup.getId(), List.of(textAnswer)); + Review review7 = new Review(1L, reviewGroup.getId(), List.of(textAnswer)); + Review review8 = new Review(1L, reviewGroup.getId(), List.of(textAnswer)); + Review review9 = new Review(1L, reviewGroup.getId(), List.of(textAnswer)); + Review review10 = new Review(1L, reviewGroup.getId(), List.of(textAnswer)); reviewRepository.saveAll( List.of(review1, review2, review3, review4, review5, review6, review7, review8, review9, review10)); @@ -76,13 +51,13 @@ class ReviewListMapperTest { int size = 5; // when - List responses = reviewListMapper.mapToReviewList( + List responses = reviewListMapper.mapToReviewList( reviewGroup, lastReviewId, size); // then assertAll( () -> assertThat(responses).hasSize(size), - () -> assertThat(responses).extracting(ReviewListElementResponse::reviewId) + () -> assertThat(responses).extracting(ReceivedReviewPageElementResponse::reviewId) .containsExactly( review7.getId(), review6.getId(), review5.getId(), review4.getId(), review3.getId()) ); diff --git a/backend/src/test/java/reviewme/review/service/mapper/ReviewMapperTest.java b/backend/src/test/java/reviewme/review/service/mapper/ReviewMapperTest.java index 8ca15f312..4c28eafb3 100644 --- a/backend/src/test/java/reviewme/review/service/mapper/ReviewMapperTest.java +++ b/backend/src/test/java/reviewme/review/service/mapper/ReviewMapperTest.java @@ -2,37 +2,28 @@ 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.선택지; import static reviewme.fixture.QuestionFixture.서술형_옵션_질문; 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.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.service.dto.request.ReviewAnswerRequest; import reviewme.review.service.dto.request.ReviewRegisterRequest; -import reviewme.review.service.exception.ReviewGroupNotFoundByReviewRequestCodeException; import reviewme.reviewgroup.domain.ReviewGroup; import reviewme.reviewgroup.repository.ReviewGroupRepository; +import reviewme.reviewgroup.service.exception.ReviewGroupNotFoundByReviewRequestCodeException; import reviewme.support.ServiceTest; +import reviewme.template.domain.OptionItem; +import reviewme.template.domain.Question; import reviewme.template.domain.Section; -import reviewme.template.repository.SectionRepository; +import reviewme.template.domain.Template; import reviewme.template.repository.TemplateRepository; @ServiceTest @@ -44,34 +35,26 @@ class ReviewMapperTest { @Autowired private ReviewGroupRepository reviewGroupRepository; - @Autowired - private OptionGroupRepository optionGroupRepository; - - @Autowired - private OptionItemRepository optionItemRepository; - - @Autowired - private QuestionRepository questionRepository; - - @Autowired - private SectionRepository sectionRepository; - @Autowired private TemplateRepository templateRepository; @Test - void 텍스트가_포함된_리뷰를_생성한다() { + void 서술형_답변을_매핑한다() { // given ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹()); - Question question = questionRepository.save(서술형_필수_질문()); - Section section = sectionRepository.save(항상_보이는_섹션(List.of(question.getId()))); - templateRepository.save(템플릿(List.of(section.getId()))); + Question question = 서술형_필수_질문(); + Section section = 항상_보이는_섹션(List.of(question)); + templateRepository.save(new Template(List.of(section))); String expectedTextAnswer = "답".repeat(20); - ReviewAnswerRequest reviewAnswerRequest = new ReviewAnswerRequest(question.getId(), null, expectedTextAnswer); - ReviewRegisterRequest reviewRegisterRequest = new ReviewRegisterRequest(reviewGroup.getReviewRequestCode(), - List.of(reviewAnswerRequest)); + ReviewAnswerRequest reviewAnswerRequest = new ReviewAnswerRequest( + question.getId(), null, expectedTextAnswer + ); + ReviewRegisterRequest reviewRegisterRequest = new ReviewRegisterRequest( + reviewGroup.getReviewRequestCode(), + List.of(reviewAnswerRequest) + ); // when Review review = reviewMapper.mapToReview(reviewRegisterRequest); @@ -81,22 +64,21 @@ class ReviewMapperTest { } @Test - void 체크박스가_포함된_리뷰를_생성한다() { + void 선택형_답변을_매핑한다() { // given ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹()); + Question question = 선택형_질문(true, 2, 1); + Section section = 항상_보이는_섹션(List.of(question)); + templateRepository.save(new Template(List.of(section))); - Question question = questionRepository.save(선택형_필수_질문()); - OptionGroup optionGroup = optionGroupRepository.save(선택지_그룹(question.getId())); - OptionItem optionItem1 = optionItemRepository.save(선택지(optionGroup.getId())); - OptionItem optionItem2 = optionItemRepository.save(선택지(optionGroup.getId())); - - Section section = sectionRepository.save(항상_보이는_섹션(List.of(question.getId()))); - templateRepository.save(템플릿(List.of(section.getId()))); - - ReviewAnswerRequest reviewAnswerRequest = new ReviewAnswerRequest(question.getId(), - List.of(optionItem1.getId()), null); - ReviewRegisterRequest reviewRegisterRequest = new ReviewRegisterRequest(reviewGroup.getReviewRequestCode(), - List.of(reviewAnswerRequest)); + OptionItem optionItem = question.getOptionGroup().getOptionItems().get(0); + ReviewAnswerRequest reviewAnswerRequest = new ReviewAnswerRequest( + question.getId(), List.of(optionItem.getId()), null + ); + ReviewRegisterRequest reviewRegisterRequest = new ReviewRegisterRequest( + reviewGroup.getReviewRequestCode(), + List.of(reviewAnswerRequest) + ); // when Review review = reviewMapper.mapToReview(reviewRegisterRequest); @@ -106,68 +88,60 @@ class ReviewMapperTest { } @Test - void 필수가_아닌_질문에_답변이_없을_경우_답변을_생성하지_않는다() { + void 필수가_아닌_서술형_질문에_답변이_없으면_매핑하지_않는다() { // given ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹()); + Question question = 서술형_옵션_질문(); + Section section = 항상_보이는_섹션(List.of(question)); + templateRepository.save(new Template(List.of(section))); - Question requiredTextQuestion = questionRepository.save(서술형_필수_질문()); - Question optionalTextQuestion = questionRepository.save(서술형_옵션_질문()); + ReviewAnswerRequest answerRequest = new ReviewAnswerRequest(question.getId(), null, ""); + ReviewRegisterRequest reviewRegisterRequest = new ReviewRegisterRequest( + reviewGroup.getReviewRequestCode(), List.of(answerRequest) + ); - Question requeiredCheckBoxQuestion = questionRepository.save(선택형_필수_질문()); - OptionGroup optionGroup1 = optionGroupRepository.save(선택지_그룹(requeiredCheckBoxQuestion.getId())); - OptionItem optionItem1 = optionItemRepository.save(선택지(optionGroup1.getId())); - OptionItem optionItem2 = optionItemRepository.save(선택지(optionGroup1.getId())); + // when + Review review = reviewMapper.mapToReview(reviewRegisterRequest); - Question optionalCheckBoxQuestion = questionRepository.save(선택형_옵션_질문()); - OptionGroup optionGroup2 = optionGroupRepository.save(선택지_그룹(optionalCheckBoxQuestion.getId())); - OptionItem optionItem3 = optionItemRepository.save(선택지(optionGroup2.getId())); - OptionItem optionItem4 = optionItemRepository.save(선택지(optionGroup2.getId())); + // then + assertThat(review.getAnswersByType(TextAnswer.class)) + .extracting(TextAnswer::getQuestionId) + .isEmpty(); + } - Section section = sectionRepository.save(항상_보이는_섹션( - List.of(requiredTextQuestion.getId(), optionalTextQuestion.getId(), - requeiredCheckBoxQuestion.getId(), optionalCheckBoxQuestion.getId()))); - templateRepository.save(템플릿(List.of(section.getId()))); + @Test + void 필수가_아닌_선택형_질문에_답변이_없으면_매핑하지_않는다() { + // given + ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹()); + Question question = 선택형_질문(false, 2, 1); + Section section = 항상_보이는_섹션(List.of(question)); + templateRepository.save(new Template(List.of(section))); - String textAnswer = "답".repeat(20); - ReviewAnswerRequest requiredTextAnswerRequest = new ReviewAnswerRequest( - requiredTextQuestion.getId(), null, textAnswer - ); - ReviewAnswerRequest optionalTextAnswerRequest = new ReviewAnswerRequest( - optionalTextQuestion.getId(), null, "" - ); - ReviewAnswerRequest requiredCheckBoxAnswerRequest = new ReviewAnswerRequest( - requeiredCheckBoxQuestion.getId(), List.of(optionItem1.getId()), null - ); - ReviewAnswerRequest optionalCheckBoxAnswerRequest = new ReviewAnswerRequest( - optionalCheckBoxQuestion.getId(), List.of(), null + ReviewAnswerRequest answerRequest = new ReviewAnswerRequest(question.getId(), List.of(), null); + ReviewRegisterRequest reviewRegisterRequest = new ReviewRegisterRequest( + reviewGroup.getReviewRequestCode(), List.of(answerRequest) ); - ReviewRegisterRequest reviewRegisterRequest = new ReviewRegisterRequest(reviewGroup.getReviewRequestCode(), - List.of(requiredTextAnswerRequest, optionalTextAnswerRequest, - requiredCheckBoxAnswerRequest, optionalCheckBoxAnswerRequest)); // when Review review = reviewMapper.mapToReview(reviewRegisterRequest); // then - assertAll( - () -> assertThat(review.getAnswersByType(TextAnswer.class)) - .extracting(TextAnswer::getQuestionId) - .containsExactly(requiredTextQuestion.getId()), - () -> assertThat(review.getAnswersByType(CheckboxAnswer.class)) - .extracting(CheckboxAnswer::getQuestionId) - .containsExactly(requeiredCheckBoxQuestion.getId()) - ); + assertThat(review.getAnswersByType(CheckboxAnswer.class)) + .extracting(CheckboxAnswer::getQuestionId) + .isEmpty(); } @Test void 잘못된_리뷰_요청_코드로_리뷰를_생성할_경우_예외가_발생한다() { // given String reviewRequestCode = "notExistCode"; - Question savedQuestion = questionRepository.save(서술형_필수_질문()); + Question savedQuestion = 서술형_필수_질문(); ReviewAnswerRequest emptyTextReviewRequest = new ReviewAnswerRequest( - savedQuestion.getId(), null, ""); + savedQuestion.getId(), null, "" + ); ReviewRegisterRequest reviewRegisterRequest = new ReviewRegisterRequest( - reviewRequestCode, List.of(emptyTextReviewRequest)); + reviewRequestCode, List.of(emptyTextReviewRequest) + ); // when, then assertThatThrownBy(() -> reviewMapper.mapToReview(reviewRegisterRequest)) diff --git a/backend/src/test/java/reviewme/review/service/mapper/TextAnswerMapperTest.java b/backend/src/test/java/reviewme/review/service/mapper/TextAnswerMapperTest.java index 841e2d5a3..b7fc960cf 100644 --- a/backend/src/test/java/reviewme/review/service/mapper/TextAnswerMapperTest.java +++ b/backend/src/test/java/reviewme/review/service/mapper/TextAnswerMapperTest.java @@ -1,23 +1,16 @@ package reviewme.review.service.mapper; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import java.util.List; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; import reviewme.review.domain.TextAnswer; import reviewme.review.service.dto.request.ReviewAnswerRequest; -import reviewme.review.service.exception.TextAnswerIncludedOptionItemException; class TextAnswerMapperTest { - /* - TODO: Request를 추상화해야 할까요? - 떠오르는 방법은 아래와 같습니다. - 1: static factory method를 사용 -> 걷잡을 수 없어지지 않을까요? - 2: 다른 방식으로 추상화 ? - */ - @Test void 텍스트_답변을_요청으로부터_매핑한다() { // given @@ -31,16 +24,18 @@ class TextAnswerMapperTest { assertThat(actual.getContent()).isEqualTo("text"); } - @Test - void 텍스트_답변_요청에_옵션이_포함되어_있으면_예외를_발생시킨다() { + @ParameterizedTest + @NullSource + @ValueSource(strings = {"", " "}) + void 텍스트_답변이_비어있는_경우_null로_매핑한다(String text) { // given - ReviewAnswerRequest request = new ReviewAnswerRequest(1L, List.of(1L), "text"); + ReviewAnswerRequest request = new ReviewAnswerRequest(1L, null, text); // when TextAnswerMapper mapper = new TextAnswerMapper(); + TextAnswer actual = mapper.mapToAnswer(request); // then - assertThatThrownBy(() -> mapper.mapToAnswer(request)) - .isInstanceOf(TextAnswerIncludedOptionItemException.class); + assertThat(actual).isNull(); } } diff --git a/backend/src/test/java/reviewme/review/service/validator/AnswerValidatorTest.java b/backend/src/test/java/reviewme/review/service/validator/AnswerValidatorTest.java new file mode 100644 index 000000000..16d528f0b --- /dev/null +++ b/backend/src/test/java/reviewme/review/service/validator/AnswerValidatorTest.java @@ -0,0 +1,99 @@ +package reviewme.review.service.validator; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static reviewme.fixture.QuestionFixture.서술형_필수_질문; +import static reviewme.fixture.ReviewGroupFixture.리뷰_그룹; +import static reviewme.fixture.SectionFixture.항상_보이는_섹션; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import reviewme.review.domain.Answer; +import reviewme.review.domain.Review; +import reviewme.review.domain.TextAnswer; +import reviewme.review.repository.ReviewRepository; +import reviewme.review.service.exception.QuestionNotContainingAnswersException; +import reviewme.review.service.exception.ReviewGroupNotContainingAnswersException; +import reviewme.reviewgroup.domain.ReviewGroup; +import reviewme.reviewgroup.repository.ReviewGroupRepository; +import reviewme.support.ServiceTest; +import reviewme.template.domain.Question; +import reviewme.template.domain.Section; +import reviewme.template.domain.Template; +import reviewme.template.repository.TemplateRepository; + +@ServiceTest +class AnswerValidatorTest { + + @Autowired + private AnswerValidator answerValidator; + + @Autowired + private ReviewGroupRepository reviewGroupRepository; + + @Autowired + private ReviewRepository reviewRepository; + + @Autowired + private TemplateRepository templateRepository; + + @Test + void 답변이_질문에_속하는지_검증한다() { + // given + ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹()); + Question question1 = 서술형_필수_질문(); + Question question2 = 서술형_필수_질문(); + Section section = 항상_보이는_섹션(List.of(question1, question2)); + templateRepository.save(new Template(List.of(section))); + + List answers = List.of( + new TextAnswer(question1.getId(), "답변1"), + new TextAnswer(question2.getId(), "답변2") + ); + Review review = reviewRepository.save(new Review(1L, reviewGroup.getId(), answers)); + Set answerIds = review.getAnsweredQuestionIds(); + List firstAnswerId = List.of(answers.get(0).getId()); + + // when, then + assertAll( + () -> assertDoesNotThrow( + () -> answerValidator.validateQuestionContainsAnswers(question1.getId(), firstAnswerId)), + () -> assertThatThrownBy( + () -> answerValidator.validateQuestionContainsAnswers(question1.getId(), answerIds)) + .isInstanceOf(QuestionNotContainingAnswersException.class) + ); + } + + @Test + void 답변이_리뷰그룹에_속하는지_검증한다() { + // given + ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹()); + Question question1 = 서술형_필수_질문(); + Question question2 = 서술형_필수_질문(); + Section section = 항상_보이는_섹션(List.of(question1, question2)); + templateRepository.save(new Template(List.of(section))); + + List answers = List.of( + new TextAnswer(question1.getId(), "답변1"), + new TextAnswer(question2.getId(), "답변2") + ); + Review review = reviewRepository.save(new Review(1L, reviewGroup.getId(), answers)); + + List answerIds = review.getAnswers().stream().map(Answer::getQuestionId).toList(); + List wrongAnswerIds = new ArrayList<>(answerIds); + wrongAnswerIds.add(Long.MAX_VALUE); + + // when, then + assertAll( + () -> assertDoesNotThrow( + () -> answerValidator.validateReviewGroupContainsAnswers(reviewGroup, answerIds)), + () -> assertThatThrownBy( + () -> answerValidator.validateReviewGroupContainsAnswers(reviewGroup, wrongAnswerIds)) + .isInstanceOf(ReviewGroupNotContainingAnswersException.class) + ); + } +} diff --git a/backend/src/test/java/reviewme/review/service/validator/CheckboxAnswerValidatorTest.java b/backend/src/test/java/reviewme/review/service/validator/CheckboxTypedAnswerValidatorTest.java similarity index 56% rename from backend/src/test/java/reviewme/review/service/validator/CheckboxAnswerValidatorTest.java rename to backend/src/test/java/reviewme/review/service/validator/CheckboxTypedAnswerValidatorTest.java index 5c64c2503..4565fb02b 100644 --- a/backend/src/test/java/reviewme/review/service/validator/CheckboxAnswerValidatorTest.java +++ b/backend/src/test/java/reviewme/review/service/validator/CheckboxTypedAnswerValidatorTest.java @@ -4,37 +4,33 @@ import static reviewme.fixture.OptionGroupFixture.선택지_그룹; import static reviewme.fixture.OptionItemFixture.선택지; import static reviewme.fixture.QuestionFixture.선택형_필수_질문; +import static reviewme.fixture.SectionFixture.항상_보이는_섹션; import java.util.List; 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.service.exception.CheckBoxAnswerIncludedNotProvidedOptionItemException; import reviewme.review.service.exception.OptionGroupNotFoundByQuestionIdException; import reviewme.review.service.exception.SelectedOptionItemCountOutOfRangeException; import reviewme.review.service.exception.SubmittedQuestionNotFoundException; import reviewme.support.ServiceTest; +import reviewme.template.domain.OptionGroup; +import reviewme.template.domain.OptionItem; +import reviewme.template.domain.Question; +import reviewme.template.domain.QuestionType; +import reviewme.template.domain.Section; +import reviewme.template.domain.Template; +import reviewme.template.repository.TemplateRepository; @ServiceTest -class CheckboxAnswerValidatorTest { +class CheckboxTypedAnswerValidatorTest { @Autowired - private CheckboxAnswerValidator checkBoxAnswerValidator; + private CheckboxTypedAnswerValidator checkBoxAnswerValidator; @Autowired - private QuestionRepository questionRepository; - - @Autowired - private OptionGroupRepository optionGroupRepository; - - @Autowired - private OptionItemRepository optionItemRepository; + private TemplateRepository templateRepository; @Test void 저장되지_않은_질문에_대한_답변이면_예외가_발생한다() { @@ -50,7 +46,10 @@ class CheckboxAnswerValidatorTest { @Test void 옵션_그룹이_지정되지_않은_질문에_대한_답변이면_예외가_발생한다() { // given - Question savedQuestion = questionRepository.save(선택형_필수_질문()); + Question savedQuestion = 선택형_필수_질문(); + Section section = 항상_보이는_섹션(List.of(savedQuestion)); + templateRepository.save(new Template(List.of(section))); + CheckboxAnswer checkboxAnswer = new CheckboxAnswer(savedQuestion.getId(), List.of(1L)); // when, then @@ -61,9 +60,11 @@ class CheckboxAnswerValidatorTest { @Test void 옵션그룹에서_제공하지_않은_옵션아이템을_응답하면_예외가_발생한다() { // given - Question savedQuestion = questionRepository.save(선택형_필수_질문()); - OptionGroup savedOptionGroup = optionGroupRepository.save(선택지_그룹(savedQuestion.getId())); - OptionItem savedOptionItem = optionItemRepository.save(선택지(savedOptionGroup.getId())); + OptionItem savedOptionItem = 선택지(); + OptionGroup savedOptionGroup = 선택지_그룹(List.of(savedOptionItem)); + Question savedQuestion = new Question(true, QuestionType.CHECKBOX, savedOptionGroup, "질문", "설명", 1); + Section section = 항상_보이는_섹션(List.of(savedQuestion)); + templateRepository.save(new Template(List.of(section))); CheckboxAnswer checkboxAnswer = new CheckboxAnswer(savedQuestion.getId(), List.of(savedOptionItem.getId() + 1L)); @@ -76,14 +77,17 @@ class CheckboxAnswerValidatorTest { @Test void 옵션그룹에서_정한_최소_선택_수_보다_적게_선택하면_예외가_발생한다() { // given - Question savedQuestion = questionRepository.save(선택형_필수_질문()); - OptionGroup savedOptionGroup = optionGroupRepository.save( - new OptionGroup(savedQuestion.getId(), 2, 3) + OptionItem savedOptionItem1 = 선택지(); + OptionItem savedOptionItem2 = 선택지(); + OptionItem savedOptionItem3 = 선택지(); + OptionGroup savedOptionGroup = new OptionGroup( + List.of(savedOptionItem1, savedOptionItem2, savedOptionItem3), 2, 3 ); - OptionItem savedOptionItem1 = optionItemRepository.save(선택지(savedOptionGroup.getId())); + Question savedQuestion = new Question(true, QuestionType.CHECKBOX, savedOptionGroup, "질문", "설명", 1); + Section section = 항상_보이는_섹션(List.of(savedQuestion)); + templateRepository.save(new Template(List.of(section))); - CheckboxAnswer checkboxAnswer = new CheckboxAnswer(savedQuestion.getId(), - List.of(savedOptionItem1.getId())); + CheckboxAnswer checkboxAnswer = new CheckboxAnswer(savedQuestion.getId(), List.of(savedOptionItem1.getId())); // when, then assertThatCode(() -> checkBoxAnswerValidator.validate(checkboxAnswer)) @@ -93,15 +97,19 @@ class CheckboxAnswerValidatorTest { @Test void 옵션그룹에서_정한_최대_선택_수_보다_많이_선택하면_예외가_발생한다() { // given - Question savedQuestion = questionRepository.save(선택형_필수_질문()); - OptionGroup savedOptionGroup = optionGroupRepository.save( - new OptionGroup(savedQuestion.getId(), 1, 1) + OptionItem savedOptionItem1 = 선택지(); + OptionItem savedOptionItem2 = 선택지(); + OptionItem savedOptionItem3 = 선택지(); + OptionGroup savedOptionGroup = new OptionGroup( + List.of(savedOptionItem1, savedOptionItem2, savedOptionItem3), 1, 1 ); - OptionItem savedOptionItem1 = optionItemRepository.save(선택지(savedOptionGroup.getId(), 1)); - OptionItem savedOptionItem2 = optionItemRepository.save(선택지(savedOptionGroup.getId(), 2)); + Question savedQuestion = new Question(true, QuestionType.CHECKBOX, savedOptionGroup, "질문", "설명", 1); + Section section = 항상_보이는_섹션(List.of(savedQuestion)); + templateRepository.save(new Template(List.of(section))); CheckboxAnswer checkboxAnswer = new CheckboxAnswer( - savedQuestion.getId(), List.of(savedOptionItem1.getId(), savedOptionItem2.getId())); + savedQuestion.getId(), List.of(savedOptionItem1.getId(), savedOptionItem2.getId()) + ); // when, then assertThatCode(() -> checkBoxAnswerValidator.validate(checkboxAnswer)) diff --git a/backend/src/test/java/reviewme/review/service/validator/ReviewValidatorTest.java b/backend/src/test/java/reviewme/review/service/validator/ReviewValidatorTest.java index dca5dd59d..df0e6ac72 100644 --- a/backend/src/test/java/reviewme/review/service/validator/ReviewValidatorTest.java +++ b/backend/src/test/java/reviewme/review/service/validator/ReviewValidatorTest.java @@ -6,21 +6,13 @@ import static reviewme.fixture.OptionItemFixture.선택지; 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.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.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; @@ -29,32 +21,23 @@ import reviewme.reviewgroup.domain.ReviewGroup; import reviewme.reviewgroup.repository.ReviewGroupRepository; import reviewme.support.ServiceTest; +import reviewme.template.domain.OptionGroup; +import reviewme.template.domain.OptionItem; +import reviewme.template.domain.Question; +import reviewme.template.domain.QuestionType; import reviewme.template.domain.Section; import reviewme.template.domain.Template; -import reviewme.template.repository.SectionRepository; import reviewme.template.repository.TemplateRepository; @ServiceTest class ReviewValidatorTest { - @Autowired - private QuestionRepository questionRepository; - - @Autowired - private OptionGroupRepository optionGroupRepository; - - @Autowired - private OptionItemRepository optionItemRepository; - @Autowired private ReviewGroupRepository reviewGroupRepository; @Autowired private TemplateRepository templateRepository; - @Autowired - private SectionRepository sectionRepository; - @Autowired private ReviewValidator reviewValidator; @@ -64,43 +47,40 @@ class ReviewValidatorTest { ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹()); // 필수가 아닌 서술형 질문 저장 - Question notRequiredTextQuestion = questionRepository.save(서술형_옵션_질문()); - Section visibleSection1 = sectionRepository.save(항상_보이는_섹션(List.of(notRequiredTextQuestion.getId()), 1)); + Question notRequiredTextQuestion = 서술형_옵션_질문(); + Section visibleSection1 = 항상_보이는_섹션(List.of(notRequiredTextQuestion), 1); // 필수 선택형 질문, 섹션 저장 - Question requiredCheckQuestion = questionRepository.save(선택형_필수_질문()); - OptionGroup requiredOptionGroup = optionGroupRepository.save(선택지_그룹(requiredCheckQuestion.getId())); - OptionItem requiredOptionItem1 = optionItemRepository.save(선택지(requiredOptionGroup.getId())); - OptionItem requiredOptionItem2 = optionItemRepository.save(선택지(requiredOptionGroup.getId())); - Section visibleSection2 = sectionRepository.save(항상_보이는_섹션(List.of(requiredCheckQuestion.getId()), 2)); + OptionItem requiredOptionItem1 = 선택지(); + OptionItem requiredOptionItem2 = 선택지(); + OptionGroup requiredOptionGroup = 선택지_그룹(List.of(requiredOptionItem1, requiredOptionItem2)); + Question requiredCheckQuestion = new Question(true, QuestionType.CHECKBOX, requiredOptionGroup, "질문", "설명", 1); + Section visibleSection2 = 항상_보이는_섹션(List.of(requiredCheckQuestion), 2); // optionItem 선택에 따라서 required 가 달라지는 섹션1 저장 - Question conditionalTextQuestion1 = questionRepository.save(서술형_필수_질문()); - Question conditionalCheckQuestion = questionRepository.save(선택형_필수_질문()); - OptionGroup conditionalOptionGroup = optionGroupRepository.save(선택지_그룹(conditionalCheckQuestion.getId())); - OptionItem conditionalOptionItem = optionItemRepository.save(선택지(conditionalOptionGroup.getId())); - Section conditionalSection1 = sectionRepository.save(조건부로_보이는_섹션( - List.of(conditionalTextQuestion1.getId(), conditionalCheckQuestion.getId()), - requiredOptionItem1.getId(), 3) + OptionItem conditionalOptionItem = 선택지(); + OptionGroup conditionalOptionGroup = 선택지_그룹(List.of(conditionalOptionItem)); + Question conditionalTextQuestion = 서술형_필수_질문(); + Question conditionalCheckQuestion = new Question(true, QuestionType.CHECKBOX, conditionalOptionGroup, "질문", + "설명", 1); + Section conditionalSection1 = 조건부로_보이는_섹션( + List.of(conditionalTextQuestion, conditionalCheckQuestion), requiredOptionItem1, 3 ); // optionItem 선택에 따라서 required 가 달라지는 섹션2 저장 - Question conditionalQuestion2 = questionRepository.save(서술형_필수_질문()); - Section conditionalSection2 = sectionRepository.save(조건부로_보이는_섹션( - List.of(conditionalQuestion2.getId()), requiredOptionItem2.getId(), 3) - ); + Question conditionalQuestion2 = 서술형_필수_질문(); + Section conditionalSection2 = 조건부로_보이는_섹션(List.of(conditionalQuestion2), requiredOptionItem2, 3); // 템플릿 저장 - Template template = templateRepository.save(템플릿( - List.of(visibleSection1.getId(), visibleSection2.getId(), - conditionalSection1.getId(), conditionalSection2.getId()) - )); + Template template = templateRepository.save( + new Template(List.of(visibleSection1, visibleSection2, conditionalSection1, conditionalSection2)) + ); // 각 질문에 대한 답변 생성 TextAnswer notRequiredTextAnswer = new TextAnswer(notRequiredTextQuestion.getId(), "답변".repeat(30)); CheckboxAnswer alwaysRequiredCheckAnswer = new CheckboxAnswer(requiredCheckQuestion.getId(), List.of(requiredOptionItem1.getId())); - TextAnswer conditionalTextAnswer1 = new TextAnswer(conditionalTextQuestion1.getId(), "답변".repeat(30)); + TextAnswer conditionalTextAnswer1 = new TextAnswer(conditionalTextQuestion.getId(), "답변".repeat(30)); CheckboxAnswer conditionalCheckAnswer1 = new CheckboxAnswer(conditionalCheckQuestion.getId(), List.of(conditionalOptionItem.getId())); @@ -119,10 +99,15 @@ class ReviewValidatorTest { // given ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹()); - Question question1 = questionRepository.save(서술형_필수_질문()); - Question question2 = questionRepository.save(서술형_필수_질문()); - Section section = sectionRepository.save(항상_보이는_섹션(List.of(question1.getId()))); - Template template = templateRepository.save(템플릿(List.of(section.getId()))); + // 재공된 템플릿 + Question question1 = 서술형_필수_질문(); + Section section = 항상_보이는_섹션(List.of(question1)); + Template template = templateRepository.save(new Template(List.of(section))); + + // 다른 템플릿 + Question question2 = 서술형_필수_질문(); + Section section2 = 항상_보이는_섹션(List.of(question2)); + templateRepository.save(new Template(List.of(section2))); TextAnswer textAnswer = new TextAnswer(question2.getId(), "답변".repeat(20)); Review review = new Review(template.getId(), reviewGroup.getId(), List.of(textAnswer)); @@ -137,11 +122,10 @@ class ReviewValidatorTest { // given ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹()); - Question requiredQuestion = questionRepository.save(서술형_필수_질문()); - Question optionalQuestion = questionRepository.save(서술형_옵션_질문()); - Section section = sectionRepository.save( - 항상_보이는_섹션(List.of(requiredQuestion.getId(), optionalQuestion.getId()))); - Template template = templateRepository.save(템플릿(List.of(section.getId()))); + Question requiredQuestion = 서술형_필수_질문(); + Question optionalQuestion = 서술형_옵션_질문(); + Section section = 항상_보이는_섹션(List.of(requiredQuestion, optionalQuestion)); + Template template = templateRepository.save(new Template(List.of(section))); TextAnswer optionalTextAnswer = new TextAnswer(optionalQuestion.getId(), "답변".repeat(20)); Review review = new Review(template.getId(), reviewGroup.getId(), List.of(optionalTextAnswer)); diff --git a/backend/src/test/java/reviewme/review/service/validator/TextAnswerValidatorTest.java b/backend/src/test/java/reviewme/review/service/validator/TextTypedAnswerValidatorTest.java similarity index 67% rename from backend/src/test/java/reviewme/review/service/validator/TextAnswerValidatorTest.java rename to backend/src/test/java/reviewme/review/service/validator/TextTypedAnswerValidatorTest.java index 0e8265bb6..ea42c2cab 100644 --- a/backend/src/test/java/reviewme/review/service/validator/TextAnswerValidatorTest.java +++ b/backend/src/test/java/reviewme/review/service/validator/TextTypedAnswerValidatorTest.java @@ -4,27 +4,32 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static reviewme.fixture.QuestionFixture.서술형_옵션_질문; import static reviewme.fixture.QuestionFixture.서술형_필수_질문; +import static reviewme.fixture.SectionFixture.항상_보이는_섹션; +import java.util.List; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; import org.springframework.beans.factory.annotation.Autowired; -import reviewme.question.domain.Question; -import reviewme.question.repository.QuestionRepository; import reviewme.review.domain.TextAnswer; import reviewme.review.service.exception.InvalidTextAnswerLengthException; import reviewme.review.service.exception.SubmittedQuestionNotFoundException; import reviewme.support.ServiceTest; +import reviewme.template.domain.Question; +import reviewme.template.domain.Section; +import reviewme.template.domain.Template; +import reviewme.template.repository.TemplateRepository; @ServiceTest -class TextAnswerValidatorTest { +class TextTypedAnswerValidatorTest { @Autowired - private TextAnswerValidator textAnswerValidator; + private TextTypedAnswerValidator textAnswerValidator; @Autowired - private QuestionRepository questionRepository; + private TemplateRepository templateRepository; + // TODO: Answer의 검증을 위해 Template의 저장이 필요하다. (Question의 제한), 조금 더 위쪽 레이어에서 명세를 활용해서 처리해도 좋겠다. @Test void 저장되지_않은_질문에_대한_대답이면_예외가_발생한다() { // given @@ -41,7 +46,10 @@ class TextAnswerValidatorTest { void 필수_질문의_답변_길이가_유효하지_않으면_예외가_발생한다(int length) { // given String content = "답".repeat(length); - Question savedQuestion = questionRepository.save(서술형_필수_질문()); + Question savedQuestion = 서술형_필수_질문(); + Section section = 항상_보이는_섹션(List.of(savedQuestion)); + templateRepository.save(new Template(List.of(section))); + TextAnswer textAnswer = new TextAnswer(savedQuestion.getId(), content); // when, then @@ -53,7 +61,10 @@ class TextAnswerValidatorTest { void 선택_질문의_답변_길이가_유효하지_않으면_예외가_발생한다() { // given String content = "답".repeat(10001); - Question savedQuestion = questionRepository.save(서술형_옵션_질문()); + Question savedQuestion = 서술형_필수_질문(); + Section section = 항상_보이는_섹션(List.of(savedQuestion)); + templateRepository.save(new Template(List.of(section))); + TextAnswer textAnswer = new TextAnswer(savedQuestion.getId(), content); // when, then @@ -65,7 +76,10 @@ class TextAnswerValidatorTest { void 선택_질문은_최소_글자수_제한을_받지_않는다() { // given String content = "답".repeat(1); - Question savedQuestion = questionRepository.save(서술형_옵션_질문()); + Question savedQuestion = 서술형_옵션_질문(); + Section section = 항상_보이는_섹션(List.of(savedQuestion)); + templateRepository.save(new Template(List.of(section))); + TextAnswer textAnswer = new TextAnswer(savedQuestion.getId(), content); // when, then diff --git a/backend/src/test/java/reviewme/review/service/validator/AnswerValidatorFactoryTest.java b/backend/src/test/java/reviewme/review/service/validator/TypedTypedAnswerValidatorFactoryTest.java similarity index 68% rename from backend/src/test/java/reviewme/review/service/validator/AnswerValidatorFactoryTest.java rename to backend/src/test/java/reviewme/review/service/validator/TypedTypedAnswerValidatorFactoryTest.java index 0a6e75db5..d808c53d3 100644 --- a/backend/src/test/java/reviewme/review/service/validator/AnswerValidatorFactoryTest.java +++ b/backend/src/test/java/reviewme/review/service/validator/TypedTypedAnswerValidatorFactoryTest.java @@ -8,9 +8,9 @@ import reviewme.review.domain.Answer; import reviewme.review.domain.CheckboxAnswer; -class AnswerValidatorFactoryTest { +class TypedTypedAnswerValidatorFactoryTest { - private final AnswerValidator validator = new AnswerValidator() { + private final TypedAnswerValidator validator = new TypedAnswerValidator() { @Override public boolean supports(Class answerClass) { @@ -19,17 +19,18 @@ public boolean supports(Class answerClass) { @Override public void validate(Answer answer) { + // no-op } }; @Test void 지원하는_타입에_따른_밸리데이터를_가져온다() { // given - List validators = List.of(validator); - AnswerValidatorFactory factory = new AnswerValidatorFactory(validators); + List validators = List.of(validator); + TypedAnswerValidatorFactory factory = new TypedAnswerValidatorFactory(validators); // when - AnswerValidator actual = factory.getAnswerValidator(CheckboxAnswer.class); + TypedAnswerValidator actual = factory.getAnswerValidator(CheckboxAnswer.class); // then assertThat(actual).isEqualTo(validator); @@ -38,7 +39,7 @@ public void validate(Answer answer) { @Test void 지원하지_않는_타입에_대한_밸리데이터_요청_시_예외가_발생한다() { // given - AnswerValidatorFactory factory = new AnswerValidatorFactory(List.of()); + TypedAnswerValidatorFactory factory = new TypedAnswerValidatorFactory(List.of()); // when, then assertThatThrownBy(() -> factory.getAnswerValidator(CheckboxAnswer.class)) diff --git a/backend/src/test/java/reviewme/reviewgroup/service/ReviewGroupLookupServiceTest.java b/backend/src/test/java/reviewme/reviewgroup/service/ReviewGroupLookupServiceTest.java index a7719e52f..4c553a9a3 100644 --- a/backend/src/test/java/reviewme/reviewgroup/service/ReviewGroupLookupServiceTest.java +++ b/backend/src/test/java/reviewme/reviewgroup/service/ReviewGroupLookupServiceTest.java @@ -6,7 +6,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import reviewme.review.service.exception.ReviewGroupNotFoundByReviewRequestCodeException; +import reviewme.reviewgroup.service.exception.ReviewGroupNotFoundByReviewRequestCodeException; import reviewme.reviewgroup.domain.ReviewGroup; import reviewme.reviewgroup.repository.ReviewGroupRepository; import reviewme.reviewgroup.service.dto.ReviewGroupResponse; diff --git a/backend/src/test/java/reviewme/reviewgroup/service/ReviewGroupServiceTest.java b/backend/src/test/java/reviewme/reviewgroup/service/ReviewGroupServiceTest.java index 6bdc22f44..af46b2070 100644 --- a/backend/src/test/java/reviewme/reviewgroup/service/ReviewGroupServiceTest.java +++ b/backend/src/test/java/reviewme/reviewgroup/service/ReviewGroupServiceTest.java @@ -8,8 +8,9 @@ import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.times; +import static reviewme.fixture.QuestionFixture.서술형_필수_질문; import static reviewme.fixture.ReviewGroupFixture.리뷰_그룹; -import static reviewme.fixture.TemplateFixture.템플릿; +import static reviewme.fixture.SectionFixture.항상_보이는_섹션; import java.util.List; import org.junit.jupiter.api.Test; @@ -17,14 +18,17 @@ 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; import reviewme.reviewgroup.service.dto.ReviewGroupCreationResponse; +import reviewme.reviewgroup.service.exception.ReviewGroupNotFoundByReviewRequestCodeException; +import reviewme.reviewgroup.service.exception.ReviewGroupUnauthorizedException; import reviewme.support.ServiceTest; +import reviewme.template.domain.Question; +import reviewme.template.domain.Section; +import reviewme.template.domain.Template; import reviewme.template.repository.TemplateRepository; @ServiceTest @@ -46,7 +50,10 @@ class ReviewGroupServiceTest { @Test void 코드가_중복되는_경우_다시_생성한다() { // given - templateRepository.save(템플릿(List.of())); + Question question = 서술형_필수_질문(); + Section section = 항상_보이는_섹션(List.of(question)); + templateRepository.save(new Template(List.of(section))); + reviewGroupRepository.save(리뷰_그룹("0000", "1111")); given(randomCodeGenerator.generate(anyInt())) .willReturn("0000") // ReviewRequestCode diff --git a/backend/src/test/java/reviewme/support/CacheCleaner.java b/backend/src/test/java/reviewme/support/CacheCleaner.java new file mode 100644 index 000000000..7c96acfe6 --- /dev/null +++ b/backend/src/test/java/reviewme/support/CacheCleaner.java @@ -0,0 +1,22 @@ +package reviewme.support; + +import java.util.Objects; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; + +public class CacheCleaner { + + private final CacheManager cacheManager; + + public CacheCleaner(CacheManager cacheManager) { + this.cacheManager = cacheManager; + } + + public void execute() { + cacheManager.getCacheNames() + .stream() + .map(cacheManager::getCache) + .filter(Objects::nonNull) + .forEach(Cache::clear); + } +} diff --git a/backend/src/test/java/reviewme/support/CacheCleanerExtension.java b/backend/src/test/java/reviewme/support/CacheCleanerExtension.java new file mode 100644 index 000000000..e941b6e83 --- /dev/null +++ b/backend/src/test/java/reviewme/support/CacheCleanerExtension.java @@ -0,0 +1,15 @@ +package reviewme.support; + +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +public class CacheCleanerExtension implements BeforeEachCallback { + + @Override + public void beforeEach(ExtensionContext extensionContext) { + SpringExtension.getApplicationContext(extensionContext) + .getBean(CacheCleaner.class) + .execute(); + } +} diff --git a/backend/src/test/java/reviewme/support/ServiceTest.java b/backend/src/test/java/reviewme/support/ServiceTest.java index 34ae4b4fd..c3838a35f 100644 --- a/backend/src/test/java/reviewme/support/ServiceTest.java +++ b/backend/src/test/java/reviewme/support/ServiceTest.java @@ -12,6 +12,6 @@ @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) @SpringBootTest(webEnvironment = WebEnvironment.NONE, classes = TestConfig.class) -@ExtendWith(DatabaseCleanerExtension.class) +@ExtendWith({DatabaseCleanerExtension.class, CacheCleanerExtension.class}) public @interface ServiceTest { } diff --git a/backend/src/test/java/reviewme/template/domain/SectionTest.java b/backend/src/test/java/reviewme/template/domain/SectionTest.java index 0e3f1339b..84db41a5b 100644 --- a/backend/src/test/java/reviewme/template/domain/SectionTest.java +++ b/backend/src/test/java/reviewme/template/domain/SectionTest.java @@ -1,58 +1,45 @@ package reviewme.template.domain; import static org.assertj.core.api.Assertions.assertThat; -import static reviewme.fixture.SectionFixture.조건부로_보이는_섹션; import static reviewme.fixture.SectionFixture.항상_보이는_섹션; import java.util.List; import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; class SectionTest { @Test void 조건_옵션을_선택하면_섹션이_보인다() { // given - List questionIds = List.of(1L); - long optionId1 = 1L; - long optionId2 = 2L; - long optionId3 = 3L; + OptionItem optionItem = new OptionItem("content", 1, OptionType.CATEGORY); + ReflectionTestUtils.setField(optionItem, "id", 1L); + Question question = new Question(true, QuestionType.CHECKBOX, "question", null, 1); + Section section = new Section(VisibleType.CONDITIONAL, List.of(question), optionItem, "name", "header", 1); - Section section = 조건부로_보이는_섹션(questionIds, optionId2); - - // when - boolean actual = section.isVisibleBySelectedOptionIds(List.of(optionId1, optionId2, optionId3)); - - // then - assertThat(actual).isTrue(); + // when, then + assertThat(section.isVisibleBySelectedOptionIds(List.of(1L, 2L, 3L))).isTrue(); } @Test void 조건_옵션을_선택하지_않으면_섹션이_보이지_않는다() { // given - List questionIds = List.of(1L); - long optionId1 = 1L; - long optionId2 = 2L; - long optionId3 = 3L; - - Section section = 조건부로_보이는_섹션(questionIds, optionId2); + OptionItem optionItem = new OptionItem("content", 1, OptionType.CATEGORY); + ReflectionTestUtils.setField(optionItem, "id", 1L); + Question question = new Question(true, QuestionType.CHECKBOX, "question", null, 1); + Section section = new Section(VisibleType.CONDITIONAL, List.of(question), optionItem, "name", "header", 1); - // when - boolean actual = section.isVisibleBySelectedOptionIds(List.of(optionId1, optionId3)); - - // then - assertThat(actual).isFalse(); + // when, then + assertThat(section.isVisibleBySelectedOptionIds(List.of(2L))).isFalse(); } @Test void 타입이_ALWAYS라면_조건과_상관없이_모두_보인다() { // given - List questionIds = List.of(1L); - Section section = 항상_보이는_섹션(questionIds); - - // when - boolean actual = section.isVisibleBySelectedOptionIds(List.of()); + Question question = new Question(true, QuestionType.CHECKBOX, "question", null, 1); + Section section = 항상_보이는_섹션(List.of(question)); - // then - assertThat(actual).isTrue(); + // when, then + assertThat(section.isVisibleBySelectedOptionIds(List.of())).isTrue(); } } diff --git a/backend/src/test/java/reviewme/template/repository/SectionRepositoryTest.java b/backend/src/test/java/reviewme/template/repository/SectionRepositoryTest.java deleted file mode 100644 index 41e2699ed..000000000 --- a/backend/src/test/java/reviewme/template/repository/SectionRepositoryTest.java +++ /dev/null @@ -1,62 +0,0 @@ -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; -import reviewme.template.domain.Section; -import reviewme.template.domain.Template; - -@DataJpaTest -class SectionRepositoryTest { - - @Autowired - private SectionRepository sectionRepository; - - @Autowired - private TemplateRepository templateRepository; - - @Test - void 템플릿_아이디로_섹션을_불러온다() { - // given - List questionIds = List.of(1L); - Section section1 = sectionRepository.save(항상_보이는_섹션(questionIds)); - Section section2 = sectionRepository.save(항상_보이는_섹션(questionIds)); - Section section3 = sectionRepository.save(항상_보이는_섹션(questionIds)); - sectionRepository.save(항상_보이는_섹션(questionIds)); - List sectionIds = List.of(section1.getId(), section2.getId(), section3.getId()); - - Template template = templateRepository.save(템플릿(sectionIds)); - - // when - List
actual = sectionRepository.findAllByTemplateId(template.getId()); - - // 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() - ); - } -} diff --git a/backend/src/test/java/reviewme/template/service/SectionServiceTest.java b/backend/src/test/java/reviewme/template/service/SectionServiceTest.java deleted file mode 100644 index c7e319d99..000000000 --- a/backend/src/test/java/reviewme/template/service/SectionServiceTest.java +++ /dev/null @@ -1,60 +0,0 @@ -package reviewme.template.service; - -import static org.assertj.core.api.Assertions.assertThat; -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.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); - - // then - assertThat(actual.sections()).extracting(SectionNameResponse::name) - .containsExactly(sectionName1, sectionName2, sectionName3); - } -} diff --git a/backend/src/test/java/reviewme/template/service/TemplateServiceTest.java b/backend/src/test/java/reviewme/template/service/TemplateServiceTest.java index 7c512d99f..269964705 100644 --- a/backend/src/test/java/reviewme/template/service/TemplateServiceTest.java +++ b/backend/src/test/java/reviewme/template/service/TemplateServiceTest.java @@ -1,14 +1,24 @@ package reviewme.template.service; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static reviewme.fixture.QuestionFixture.서술형_필수_질문; import static reviewme.fixture.ReviewGroupFixture.리뷰_그룹; +import static reviewme.fixture.ReviewGroupFixture.템플릿_지정_리뷰_그룹; +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.Question; +import reviewme.template.domain.Section; +import reviewme.template.domain.Template; +import reviewme.template.domain.VisibleType; +import reviewme.template.repository.TemplateRepository; +import reviewme.template.service.dto.response.SectionNameResponse; +import reviewme.template.service.dto.response.SectionNamesResponse; import reviewme.template.service.exception.TemplateNotFoundByReviewGroupException; @ServiceTest @@ -20,23 +30,36 @@ class TemplateServiceTest { @Autowired private ReviewGroupRepository reviewGroupRepository; + @Autowired + private TemplateRepository templateRepository; + @Test - void 잘못된_리뷰_요청_코드로_리뷰_작성폼을_요청할_경우_예외가_발생한다() { + void 리뷰이에게_작성될_리뷰_양식_생성_시_저장된_템플릿이_없을_경우_예외가_발생한다() { // given ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹()); + String reviewRequestCode = reviewGroup.getReviewRequestCode(); // when, then - assertThatThrownBy(() -> templateService.generateReviewForm(reviewGroup.getReviewRequestCode() + " ")) - .isInstanceOf(ReviewGroupNotFoundByReviewRequestCodeException.class); + assertThatThrownBy(() -> templateService.generateReviewForm(reviewRequestCode)) + .isInstanceOf(TemplateNotFoundByReviewGroupException.class); } @Test - void 리뷰이에게_작성될_리뷰_양식_생성_시_저장된_템플릿이_없을_경우_예외가_발생한다() { + void 템플릿에_있는_섹션_이름_목록을_응답한다() { // given - ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹()); + Question question1 = 서술형_필수_질문(1); + Question question2 = 서술형_필수_질문(1); - // when, then - assertThatThrownBy(() -> templateService.generateReviewForm(reviewGroup.getReviewRequestCode())) - .isInstanceOf(TemplateNotFoundByReviewGroupException.class); + Section section1 = new Section(VisibleType.ALWAYS, List.of(question1), null, "섹션1", "헤더", 1); + Section section2 = new Section(VisibleType.ALWAYS, List.of(question2), null, "섹션2", "헤더", 2); + Template template = templateRepository.save(new Template(List.of(section1, section2))); + ReviewGroup reviewGroup = reviewGroupRepository.save(템플릿_지정_리뷰_그룹(template.getId())); + + // when + SectionNamesResponse actual = templateService.getSectionNames(reviewGroup); + + // then + assertThat(actual.sections()).extracting(SectionNameResponse::name) + .containsExactly("섹션1", "섹션2"); } } diff --git a/backend/src/test/java/reviewme/template/service/mapper/TemplateMapperTest.java b/backend/src/test/java/reviewme/template/service/mapper/TemplateMapperTest.java deleted file mode 100644 index e1a0f1853..000000000 --- a/backend/src/test/java/reviewme/template/service/mapper/TemplateMapperTest.java +++ /dev/null @@ -1,169 +0,0 @@ -package reviewme.template.service.mapper; - -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.선택지; -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.Test; -import org.springframework.beans.factory.annotation.Autowired; -import reviewme.question.domain.OptionGroup; -import reviewme.question.domain.Question; -import reviewme.question.repository.OptionGroupRepository; -import reviewme.question.repository.OptionItemRepository; -import reviewme.question.repository.QuestionRepository; -import reviewme.reviewgroup.domain.ReviewGroup; -import reviewme.reviewgroup.repository.ReviewGroupRepository; -import reviewme.support.ServiceTest; -import reviewme.template.domain.Section; -import reviewme.template.service.exception.MissingOptionItemsInOptionGroupException; -import reviewme.template.service.exception.SectionInTemplateNotFoundException; -import reviewme.template.repository.SectionRepository; -import reviewme.template.repository.TemplateRepository; -import reviewme.template.service.dto.response.QuestionResponse; -import reviewme.template.service.dto.response.SectionResponse; -import reviewme.template.service.dto.response.TemplateResponse; - -@ServiceTest -class TemplateMapperTest { - - @Autowired - private TemplateMapper templateMapper; - - @Autowired - private TemplateRepository templateRepository; - - @Autowired - private SectionRepository sectionRepository; - - @Autowired - private QuestionRepository questionRepository; - - @Autowired - private OptionGroupRepository optionGroupRepository; - - @Autowired - private OptionItemRepository optionItemRepository; - - @Autowired - private ReviewGroupRepository reviewGroupRepository; - - @Test - void 리뷰_그룹과_템플릿으로_템플릿_응답을_매핑한다() { - // given - Question question1 = questionRepository.save(서술형_필수_질문()); - Question question2 = questionRepository.save(서술형_필수_질문()); - - OptionGroup optionGroup = optionGroupRepository.save(선택지_그룹(question1.getId())); - optionItemRepository.save(선택지(optionGroup.getId())); - - Section section1 = sectionRepository.save(항상_보이는_섹션(List.of(question1.getId()))); - Section section2 = sectionRepository.save(항상_보이는_섹션(List.of(question2.getId()))); - - templateRepository.save(템플릿(List.of(section1.getId(), section2.getId()))); - - ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹()); - - // when - TemplateResponse templateResponse = templateMapper.mapToTemplateResponse(reviewGroup); - - // then - assertAll( - () -> assertThat(templateResponse.revieweeName()).isEqualTo(reviewGroup.getReviewee()), - () -> assertThat(templateResponse.projectName()).isEqualTo(reviewGroup.getProjectName()), - () -> assertThat(templateResponse.sections()).hasSize(2), - () -> assertThat(templateResponse.sections().get(0).header()).isEqualTo(section1.getHeader()), - () -> assertThat(templateResponse.sections().get(0).questions()).hasSize(1), - () -> assertThat(templateResponse.sections().get(1).header()).isEqualTo(section2.getHeader()), - () -> assertThat(templateResponse.sections().get(1).questions()).hasSize(1) - ); - } - - @Test - void 섹션의_선택된_옵션이_필요없는_경우_제공하지_않는다() { - // given - Question question = questionRepository.save(서술형_필수_질문()); - Section section = sectionRepository.save(항상_보이는_섹션(List.of(question.getId()))); - templateRepository.save(템플릿(List.of(section.getId()))); - - ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹()); - - // when - TemplateResponse templateResponse = templateMapper.mapToTemplateResponse(reviewGroup); - - // then - SectionResponse sectionResponse = templateResponse.sections().get(0); - assertThat(sectionResponse.onSelectedOptionId()).isNull(); - } - - @Test - void 가이드라인이_없는_경우_가이드_라인을_제공하지_않는다() { - // given - Question question = questionRepository.save(서술형_필수_질문()); - Section section = sectionRepository.save(항상_보이는_섹션(List.of(question.getId()))); - templateRepository.save(템플릿(List.of(section.getId()))); - - ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹()); - - // when - TemplateResponse templateResponse = templateMapper.mapToTemplateResponse(reviewGroup); - - // then - QuestionResponse questionResponse = templateResponse.sections().get(0).questions().get(0); - assertAll( - () -> assertThat(questionResponse.hasGuideline()).isFalse(), - () -> assertThat(questionResponse.guideline()).isNull() - ); - } - - @Test - void 옵션_그룹이_없는_질문의_경우_옵션_그룹을_제공하지_않는다() { - // given - Question question = questionRepository.save(서술형_필수_질문()); - Section section = sectionRepository.save(항상_보이는_섹션(List.of(question.getId()))); - templateRepository.save(템플릿(List.of(section.getId()))); - - ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹()); - - // when - TemplateResponse templateResponse = templateMapper.mapToTemplateResponse(reviewGroup); - - // then - QuestionResponse questionResponse = templateResponse.sections().get(0).questions().get(0); - assertThat(questionResponse.optionGroup()).isNull(); - } - - @Test - void 템플릿_매핑_시_템플릿에_제공할_섹션이_없을_경우_예외가_발생한다() { - // given - templateRepository.save(템플릿(List.of(1L))); - ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹()); - - // when, then - assertThatThrownBy(() -> templateMapper.mapToTemplateResponse(reviewGroup)) - .isInstanceOf(SectionInTemplateNotFoundException.class); - } - - @Test - void 템플릿_매핑_시_옵션_그룹에_해당하는_옵션_아이템이_없을_경우_예외가_발생한다() { - // given - Question question = questionRepository.save(선택형_필수_질문()); - optionGroupRepository.save(선택지_그룹(question.getId())); - - Section section = sectionRepository.save(항상_보이는_섹션(List.of(question.getId()))); - templateRepository.save(템플릿(List.of(section.getId()))); - - ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹()); - - // when, then - assertThatThrownBy(() -> templateMapper.mapToTemplateResponse(reviewGroup)) - .isInstanceOf(MissingOptionItemsInOptionGroupException.class); - } -} diff --git a/backend/src/test/resources/application.yml b/backend/src/test/resources/application.yml index f18542246..541b9ff06 100644 --- a/backend/src/test/resources/application.yml +++ b/backend/src/test/resources/application.yml @@ -13,6 +13,8 @@ spring: ddl-auto: update flyway: enabled: false + cache: + type: simple springdoc: swagger-ui: @@ -38,9 +40,3 @@ logging: cors: allowed-origins: - https://allowed-domain.com - -request-limit: - threshold: 3 - duration: 1s - host: localhost - port: 6379 diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs index dd825e3f4..ea0dcdbdb 100644 --- a/frontend/.eslintrc.cjs +++ b/frontend/.eslintrc.cjs @@ -26,6 +26,17 @@ module.exports = { rules: { 'react/react-in-jsx-scope': 'off', 'react/no-unknown-property': ['error', { ignore: ['css'] }], + '@typescript-eslint/naming-convention': [ + 'error', + { + selector: 'interface', + format: ['PascalCase'], + }, + { + selector: 'typeAlias', + format: ['PascalCase'], + }, + ], 'import/order': [ 'error', { diff --git a/frontend/src/apis/group.ts b/frontend/src/apis/group.ts index 2f7c2ed7e..6cc3374c6 100644 --- a/frontend/src/apis/group.ts +++ b/frontend/src/apis/group.ts @@ -1,4 +1,4 @@ -import { INVALID_REVIEW_PASSWORD_MESSAGE } from '@/constants'; +import { ERROR_BOUNDARY_IGNORE_ERROR, INVALID_REVIEW_PASSWORD_MESSAGE } from '@/constants'; import { PasswordResponse, ReviewGroupData } from '@/types'; import createApiErrorMessage from './apiErrorMessageCreator'; @@ -7,7 +7,7 @@ import endPoint from './endpoints'; export interface DataForReviewRequestCode { revieweeName: string; projectName: string; - groupAccessCode: string; + groupAccessCode?: string; } export const postDataForReviewRequestCodeApi = async (dataForReviewRequestCode: DataForReviewRequestCode) => { @@ -16,11 +16,12 @@ export const postDataForReviewRequestCodeApi = async (dataForReviewRequestCode: headers: { 'Content-Type': 'application/json', }, + // TODO : 회원 리뷰 링크 API 문서 나오면 비밀번호 관련해 변경해야함 body: JSON.stringify(dataForReviewRequestCode), }); if (!response.ok) { - throw new Error(createApiErrorMessage(response.status)); + throw new Error(`${createApiErrorMessage(response.status)} ${ERROR_BOUNDARY_IGNORE_ERROR}`); } const data = await response.json(); diff --git a/frontend/src/assets/backButton.svg b/frontend/src/assets/backButton.svg new file mode 100644 index 000000000..3693f7b99 --- /dev/null +++ b/frontend/src/assets/backButton.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/emptyContentIcon.svg b/frontend/src/assets/emptyContentIcon.svg new file mode 100644 index 000000000..1c1d3e1d9 --- /dev/null +++ b/frontend/src/assets/emptyContentIcon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/src/assets/github.svg b/frontend/src/assets/github.svg new file mode 100644 index 000000000..518b64d8a --- /dev/null +++ b/frontend/src/assets/github.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/githubWhiteLogo.svg b/frontend/src/assets/githubWhiteLogo.svg new file mode 100644 index 000000000..dc79940e2 --- /dev/null +++ b/frontend/src/assets/githubWhiteLogo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/src/assets/logout.svg b/frontend/src/assets/logout.svg new file mode 100644 index 000000000..4cb4c48a4 --- /dev/null +++ b/frontend/src/assets/logout.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/menu.svg b/frontend/src/assets/menu.svg new file mode 100644 index 000000000..8c279f0a2 --- /dev/null +++ b/frontend/src/assets/menu.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/openedBook.svg b/frontend/src/assets/openedBook.svg new file mode 100644 index 000000000..233c9d6fe --- /dev/null +++ b/frontend/src/assets/openedBook.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/slideArrows.svg b/frontend/src/assets/slideArrows.svg new file mode 100644 index 000000000..acf1609d9 --- /dev/null +++ b/frontend/src/assets/slideArrows.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/assets/user.svg b/frontend/src/assets/user.svg new file mode 100644 index 000000000..9a80b77a0 --- /dev/null +++ b/frontend/src/assets/user.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/components/ReviewListItem/index.tsx b/frontend/src/components/ReviewListItem/index.tsx new file mode 100644 index 000000000..75dacaf8f --- /dev/null +++ b/frontend/src/components/ReviewListItem/index.tsx @@ -0,0 +1,13 @@ +// 임시 컴포넌트! 작성한 리뷰 확인 && 받은 리뷰 확인 아이템 + +import * as S from './styles'; + +interface ReviewListItemProps { + handleClick: () => void; +} + +const ReviewListItem = ({ handleClick }: ReviewListItemProps) => { + return 리뷰 목록 아이템입니다; +}; + +export default ReviewListItem; diff --git a/frontend/src/components/ReviewListItem/styles.ts b/frontend/src/components/ReviewListItem/styles.ts new file mode 100644 index 000000000..4d1408aa0 --- /dev/null +++ b/frontend/src/components/ReviewListItem/styles.ts @@ -0,0 +1,31 @@ +import styled from '@emotion/styled'; + +import media from '@/utils/media'; + +export const ReviewListItem = styled.li` + display: flex; + flex-direction: column; + + min-width: ${({ theme }) => theme.writtenReviewLayoutSize.largeMinWidth}; + max-width: ${({ theme }) => theme.writtenReviewLayoutSize.largeMaxWidth}; + min-height: 20rem; + max-height: 24rem; + + border: 0.2rem solid ${({ theme }) => theme.colors.placeholder}; + border-radius: ${({ theme }) => theme.borderRadius.basic}; + + ${media.medium} { + min-width: 62vw; + min-height: 18vh; + } + + ${media.small} { + min-width: 65vw; + min-height: 14vh; + } + + ${media.xSmall} { + min-width: 70vw; + min-height: 14vh; + } +`; diff --git a/frontend/src/components/common/Accordion/styles.ts b/frontend/src/components/common/Accordion/styles.ts index b701f6c60..49a5a4836 100644 --- a/frontend/src/components/common/Accordion/styles.ts +++ b/frontend/src/components/common/Accordion/styles.ts @@ -24,7 +24,6 @@ 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}`}; `; @@ -36,7 +35,8 @@ export const AccordionButton = styled.button` width: 100%; height: fit-content; - min-height: 3rem; + min-height: 5rem; + padding: 1rem; `; export const AccordionTitle = styled.p` diff --git a/frontend/src/components/common/BackButton/index.tsx b/frontend/src/components/common/BackButton/index.tsx new file mode 100644 index 000000000..c2e295c27 --- /dev/null +++ b/frontend/src/components/common/BackButton/index.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { NavigateOptions, useNavigate } from 'react-router'; + +import BackButtonIcon from '@/assets/backButton.svg'; + +import * as S from './styles'; + +interface BackButtonProps { + prevPath: string; + navigateOptions?: NavigateOptions; + buttonStyle?: React.CSSProperties; + wrapperStyle?: React.CSSProperties; +} + +const BackButton = ({ prevPath, navigateOptions, buttonStyle, wrapperStyle }: BackButtonProps) => { + const navigate = useNavigate(); + + const handleBackButtonClick = () => { + navigate(prevPath, navigateOptions); + }; + + return ( + + + 뒤로가기 버튼 + + + ); +}; + +export default BackButton; diff --git a/frontend/src/components/common/BackButton/styles.ts b/frontend/src/components/common/BackButton/styles.ts new file mode 100644 index 000000000..46a909b04 --- /dev/null +++ b/frontend/src/components/common/BackButton/styles.ts @@ -0,0 +1,20 @@ +import styled from '@emotion/styled'; + +interface BackButtonStyleProps { + $style?: React.CSSProperties; +} + +export const BackButtonWrapper = styled.div` + display: flex; + justify-content: flex-start; + width: 100%; + + ${({ $style }) => $style && { ...$style }} +`; + +export const BackButton = styled.button` + width: 3.5rem; + height: 3.5rem; + + ${({ $style }) => $style && { ...$style }} +`; diff --git a/frontend/src/pages/HomePage/components/CopyTextButton/index.tsx b/frontend/src/components/common/CopyTextButton/index.tsx similarity index 100% rename from frontend/src/pages/HomePage/components/CopyTextButton/index.tsx rename to frontend/src/components/common/CopyTextButton/index.tsx diff --git a/frontend/src/pages/HomePage/components/CopyTextButton/styles.ts b/frontend/src/components/common/CopyTextButton/styles.ts similarity index 100% rename from frontend/src/pages/HomePage/components/CopyTextButton/styles.ts rename to frontend/src/components/common/CopyTextButton/styles.ts diff --git a/frontend/src/components/common/EmptyContent/index.tsx b/frontend/src/components/common/EmptyContent/index.tsx new file mode 100644 index 000000000..4bad95f0d --- /dev/null +++ b/frontend/src/components/common/EmptyContent/index.tsx @@ -0,0 +1,28 @@ +import Icon from '@/assets/emptyContentIcon.svg'; +import { EssentialPropsWithChildren } from '@/types'; + +import * as S from './styles'; + +interface EmptyContentProps { + iconHeight?: string; + iconWidth?: string; + iconMessageGap?: string; + messageFontSize?: string; +} + +const EmptyContent = ({ + iconHeight, + iconWidth, + iconMessageGap, + messageFontSize, + children, +}: EssentialPropsWithChildren) => { + return ( + + + {children} + + ); +}; + +export default EmptyContent; diff --git a/frontend/src/components/common/EmptyContent/styles.ts b/frontend/src/components/common/EmptyContent/styles.ts new file mode 100644 index 000000000..3a53166c2 --- /dev/null +++ b/frontend/src/components/common/EmptyContent/styles.ts @@ -0,0 +1,23 @@ +import styled from '@emotion/styled'; + +export const EmptyContent = styled.div<{ $iconMessageGap?: string }>` + display: flex; + flex-direction: column; + gap: ${(props) => props.$iconMessageGap ?? '3.2rem'}; + align-items: center; +`; +interface ImgProps { + $height?: string; + $width?: string; +} +export const Img = styled.img` + aspect-ratio: 39/25; + width: ${(props) => props.$width || 'auto'}; + height: ${(props) => props.$height || (props.$width ? 'auto' : '19.7rem')}; +`; + +export const MessageContainer = styled.div<{ $messageFontSize?: string }>` + font-size: ${(props) => props.$messageFontSize ?? props.theme.fontSize.medium}; + font-weight: ${({ theme }) => theme.fontWeight.semibold}; + color: ${({ theme }) => theme.colors.emptyContentText}; +`; diff --git a/frontend/src/components/common/NavigationTab/NavItem/index.tsx b/frontend/src/components/common/NavigationTab/NavItem/index.tsx new file mode 100644 index 000000000..88d4f9682 --- /dev/null +++ b/frontend/src/components/common/NavigationTab/NavItem/index.tsx @@ -0,0 +1,17 @@ +import * as S from './styles'; + +interface NavItemProps { + label: string; + $isSelected: boolean; + onClick: () => void; +} + +const NavItem = ({ label, $isSelected, onClick }: NavItemProps) => { + return ( + + + + ); +}; + +export default NavItem; diff --git a/frontend/src/components/common/NavigationTab/NavItem/styles.ts b/frontend/src/components/common/NavigationTab/NavItem/styles.ts new file mode 100644 index 000000000..f7f144cd2 --- /dev/null +++ b/frontend/src/components/common/NavigationTab/NavItem/styles.ts @@ -0,0 +1,33 @@ +import styled from '@emotion/styled'; + +import media from '@/utils/media'; + +interface NavItemProps { + $isSelected: boolean; +} + +export const NavItem = styled.li` + border-bottom: 0.3rem solid ${({ theme, $isSelected }) => ($isSelected ? theme.colors.primary : 'none')}; + padding: 0 1rem 1.3rem 1rem; + + button { + font-weight: ${({ theme }) => theme.fontWeight.semibold}; + color: ${({ theme, $isSelected }) => ($isSelected ? theme.colors.black : theme.colors.disabled)}; + + &:hover { + color: ${({ theme }) => theme.colors.black}; + } + } + + ${media.xSmall} { + display: flex; + flex: 1; + justify-content: center; + + margin: 0 2rem; + } + + ${media.xxSmall} { + margin: 0 1.6rem; + } +`; diff --git a/frontend/src/components/common/NavigationTab/index.tsx b/frontend/src/components/common/NavigationTab/index.tsx new file mode 100644 index 000000000..2ad073e60 --- /dev/null +++ b/frontend/src/components/common/NavigationTab/index.tsx @@ -0,0 +1,27 @@ +import useNavigationTabs from '@/hooks/useNavigationTabs'; + +import NavItem from './NavItem'; +import * as S from './styles'; + +const NavigationTab = () => { + const { currentTabIndex, tabList } = useNavigationTabs(); + + return ( + + + {tabList.map((tab, index) => { + return ( + + ); + })} + + + ); +}; + +export default NavigationTab; diff --git a/frontend/src/components/common/NavigationTab/styles.ts b/frontend/src/components/common/NavigationTab/styles.ts new file mode 100644 index 000000000..6b2472ae2 --- /dev/null +++ b/frontend/src/components/common/NavigationTab/styles.ts @@ -0,0 +1,29 @@ +import styled from '@emotion/styled'; + +import media from '@/utils/media'; + +export const NavContainer = styled.nav` + position: relative; + display: flex; + width: calc(100vw - ${({ theme }) => theme.scrollbarWidth.basic}); + height: 4rem; + + border-bottom: 0.1rem solid ${({ theme }) => theme.colors.lightGray}; + + ${media.small} { + width: calc(100vw - ${({ theme }) => theme.scrollbarWidth.small}); + } +`; + +export const NavList = styled.ul` + display: flex; + gap: 3rem; + padding: 0 2.5rem; + list-style-type: none; + + ${media.xSmall} { + gap: 0; + width: 100%; + padding: 0; + } +`; diff --git a/frontend/src/components/common/OptionSwitch/styles.ts b/frontend/src/components/common/OptionSwitch/styles.ts index b2be3f531..2e2bf729f 100644 --- a/frontend/src/components/common/OptionSwitch/styles.ts +++ b/frontend/src/components/common/OptionSwitch/styles.ts @@ -10,13 +10,12 @@ export const OptionSwitchContainer = styled.ul` width: 20rem; height: 4.4rem; + margin-top: 0.9rem; padding: 0.7rem; background-color: ${({ theme }) => theme.colors.lightGray}; border-radius: ${({ theme }) => theme.borderRadius.basic}; - margin-top: 0.9rem; - @media screen and (max-width: 530px) { width: 100%; } diff --git a/frontend/src/components/ReviewCard/index.tsx b/frontend/src/components/common/ReviewCard/index.tsx similarity index 61% rename from frontend/src/components/ReviewCard/index.tsx rename to frontend/src/components/common/ReviewCard/index.tsx index 92baf19a9..57b7de1e6 100644 --- a/frontend/src/components/ReviewCard/index.tsx +++ b/frontend/src/components/common/ReviewCard/index.tsx @@ -1,5 +1,8 @@ import { Category } from '@/types'; +import ReviewDate from '../ReviewDate'; +import ReviewKeyword from '../ReviewKeyword'; + import * as S from './styles'; interface ReviewCardProps { @@ -10,19 +13,21 @@ interface ReviewCardProps { } const ReviewCard = ({ createdAt, contentPreview, categories, handleClick }: ReviewCardProps) => { + const date = new Date(createdAt); + return ( - {createdAt} + {contentPreview} - - {categories.map((category) => ( -
{category.content}
+ + {categories.map(({ optionId, content }) => ( + ))} -
+
diff --git a/frontend/src/components/ReviewCard/styles.ts b/frontend/src/components/common/ReviewCard/styles.ts similarity index 61% rename from frontend/src/components/ReviewCard/styles.ts rename to frontend/src/components/common/ReviewCard/styles.ts index 5d333823e..93786a694 100644 --- a/frontend/src/components/ReviewCard/styles.ts +++ b/frontend/src/components/common/ReviewCard/styles.ts @@ -5,15 +5,15 @@ import media from '@/utils/media'; export const Layout = styled.div` display: flex; flex-direction: column; - border: 0.1rem solid ${({ theme }) => theme.colors.lightGray}; + border: 0.2rem solid ${({ theme }) => theme.colors.disabled}; border-radius: 1rem; &:hover { cursor: pointer; - border: 0.15rem solid ${({ theme }) => theme.colors.primaryHover}; + border: 0.2rem solid ${({ theme }) => theme.colors.primaryHover}; - & > div:first-of-type { - background-color: ${({ theme }) => theme.colors.lightPurple}; + & > div { + background-color: ${({ theme }) => theme.colors.palePurple}; } } `; @@ -23,27 +23,22 @@ export const Header = styled.div` align-items: center; width: 100%; - height: 3.8rem; + padding: 2rem 0 0 2.5rem; - background-color: ${({ theme }) => theme.colors.lightGray}; border-radius: 1rem 1rem 0 0; `; -export const Date = styled.p` - height: fit-content; - padding: 0 3rem; - font-size: 1.3rem; -`; - export const Main = styled.div` display: flex; flex-direction: column; gap: 2rem; width: 100%; - padding: 2rem 3rem; + padding: 2rem 2.5rem; font-size: 1.6rem; + + border-radius: 0 0 1rem 1rem; `; export const ContentPreview = styled.p` @@ -52,10 +47,10 @@ export const ContentPreview = styled.p` -webkit-box-orient: vertical; -webkit-line-clamp: 3; - height: 6rem; + height: 7.5rem; padding-right: 2rem; - line-height: 2rem; + line-height: 2.5rem; text-overflow: ellipsis; overflow-wrap: break-word; `; @@ -73,21 +68,16 @@ export const Footer = styled.div` } `; -export const Keyword = styled.div` +export const ReviewKeywordList = styled.ul` display: flex; flex-wrap: wrap; gap: 2.5rem; align-items: center; - font-size: 1.4rem; + font-size: 1.2rem; + list-style-type: none; ${media.small} { gap: 1.2rem; } - - div { - padding: 0.5rem 3rem; - background-color: ${({ theme }) => theme.colors.lightPurple}; - border-radius: 0.8rem; - } `; diff --git a/frontend/src/components/common/ReviewDate/index.tsx b/frontend/src/components/common/ReviewDate/index.tsx index 8c86290cc..921d3e9b5 100644 --- a/frontend/src/components/common/ReviewDate/index.tsx +++ b/frontend/src/components/common/ReviewDate/index.tsx @@ -1,4 +1,3 @@ -import ClockIcon from '@/assets/clock.svg'; import { formatDate } from '@/utils'; import * as S from './styles'; @@ -10,15 +9,11 @@ export interface ReviewDateProps { const ReviewDate = ({ date, dateTitle }: ReviewDateProps) => { const { year, month, day } = formatDate(date); + return ( - - - {dateTitle} - : - - {year}-{month}-{day} + {dateTitle} | {year}.{month}.{day} ); diff --git a/frontend/src/components/common/ReviewDate/styles.ts b/frontend/src/components/common/ReviewDate/styles.ts index 6af512685..dc65be47b 100644 --- a/frontend/src/components/common/ReviewDate/styles.ts +++ b/frontend/src/components/common/ReviewDate/styles.ts @@ -2,31 +2,13 @@ import styled from '@emotion/styled'; import media from '@/utils/media'; -export const ReviewDateText = styled.div` - display: inline-flex; - align-items: center; - justify-content: center; - - ${media.small} { - display: none; - } -`; - -export const ClockImg = styled.img` - width: auto; - height: 1.6rem; - margin-right: 0.8rem; -`; - export const ReviewDate = styled.div` display: flex; align-items: center; + font-weight: ${({ theme }) => theme.fontWeight.semibold}; + color: ${({ theme }) => theme.colors.gray}; ${media.xSmall} { font-size: ${({ theme }) => theme.fontSize.small}; } `; - -export const Colon = styled.span` - margin: 0 1rem; -`; diff --git a/frontend/src/components/common/ReviewKeyword/index.tsx b/frontend/src/components/common/ReviewKeyword/index.tsx new file mode 100644 index 000000000..8b77f0c22 --- /dev/null +++ b/frontend/src/components/common/ReviewKeyword/index.tsx @@ -0,0 +1,11 @@ +import formatKeyword from '@/utils/formatKeyword'; + +import * as S from './styles'; + +const ReviewKeyword = ({ content }: { content: string }) => { + const formattedKeyword = formatKeyword(content); + + return {formattedKeyword}; +}; + +export default ReviewKeyword; diff --git a/frontend/src/components/common/ReviewKeyword/styles.ts b/frontend/src/components/common/ReviewKeyword/styles.ts new file mode 100644 index 000000000..7c5345aae --- /dev/null +++ b/frontend/src/components/common/ReviewKeyword/styles.ts @@ -0,0 +1,11 @@ +import styled from '@emotion/styled'; + +export const ReviewKeyword = styled.li` + padding: 0.5rem 2rem; + + font-weight: ${({ theme }) => theme.fontWeight.semibold}; + color: ${({ theme }) => theme.colors.primary}; + + background-color: ${({ theme }) => theme.colors.lightPurple}; + border-radius: 1.4rem; +`; diff --git a/frontend/src/components/common/Toast/index.tsx b/frontend/src/components/common/Toast/index.tsx index d9219477e..ffa621dfb 100644 --- a/frontend/src/components/common/Toast/index.tsx +++ b/frontend/src/components/common/Toast/index.tsx @@ -17,15 +17,15 @@ interface ToastProps { duration: number; position: ToastPositionType; handleOpenModal: (isOpen: boolean) => void; - handleModalMessage: (message: string) => void; + handleModalMessage?: (message: string) => void; } const Toast = ({ icon, message, duration, position, handleOpenModal, handleModalMessage }: ToastProps) => { useEffect(() => { const timer = setTimeout(() => { handleOpenModal(false); - handleModalMessage(''); - }, duration * 1000); + if (handleModalMessage) handleModalMessage(''); + }, duration); return () => clearTimeout(timer); }, [handleOpenModal]); diff --git a/frontend/src/components/common/UndraggableWrapper/styles.ts b/frontend/src/components/common/UndraggableWrapper/styles.ts index 7781b2819..8defcac00 100644 --- a/frontend/src/components/common/UndraggableWrapper/styles.ts +++ b/frontend/src/components/common/UndraggableWrapper/styles.ts @@ -5,6 +5,4 @@ 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/common/index.tsx b/frontend/src/components/common/index.tsx index 2a15f749f..4a34fce45 100644 --- a/frontend/src/components/common/index.tsx +++ b/frontend/src/components/common/index.tsx @@ -10,7 +10,9 @@ 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 EmptyContent } from './EmptyContent'; export { default as OptionSwitch } from './OptionSwitch'; export { default as ReviewEmptySection } from './ReviewEmptySection'; export * from './modals'; +export { default as CopyTextButton } from './CopyTextButton'; +export { default as ReviewCard } from './ReviewCard'; diff --git a/frontend/src/components/common/modals/index.tsx b/frontend/src/components/common/modals/index.tsx index e6c52e598..9918f5d51 100644 --- a/frontend/src/components/common/modals/index.tsx +++ b/frontend/src/components/common/modals/index.tsx @@ -1,3 +1,4 @@ export { default as ConfirmModal } from './ConfirmModal'; export { default as AlertModal } from './AlertModal'; export { default as ErrorAlertModal } from './ErrorAlertModal'; +export { default as ContentModal } from './ContentModal'; diff --git a/frontend/src/components/error/ErrorSuspenseContainer/index.tsx b/frontend/src/components/error/ErrorSuspenseContainer/index.tsx index 5752adb6b..36f2e8b93 100644 --- a/frontend/src/components/error/ErrorSuspenseContainer/index.tsx +++ b/frontend/src/components/error/ErrorSuspenseContainer/index.tsx @@ -1,5 +1,5 @@ import { QueryErrorResetBoundary } from '@tanstack/react-query'; -import { lazy, Suspense } from 'react'; +import { lazy, ReactNode, Suspense } from 'react'; import { EssentialPropsWithChildren } from '@/types'; @@ -9,18 +9,20 @@ import ErrorFallback from '../ErrorFallback'; const LoadingPage = lazy(() => import('@/pages/LoadingPage')); interface ErrorSuspenseContainerProps { - fallback?: React.ComponentType; + errorFallback?: React.ComponentType; + suspenseFallback?: ReactNode; } const ErrorSuspenseContainer = ({ children, - fallback = ErrorFallback, + errorFallback = ErrorFallback, + suspenseFallback = , }: EssentialPropsWithChildren) => { return ( {({ reset }) => ( - - }>{children} + + {children} )} diff --git a/frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlight.ts b/frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlight.ts index 703f8acd7..2b91ac8e3 100644 --- a/frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlight.ts +++ b/frontend/src/components/highlight/components/HighlightEditor/hooks/useHighlight.ts @@ -1,11 +1,6 @@ -import { useEffect, useState } from 'react'; +import { useState } from 'react'; -import { - EDITOR_ANSWER_CLASS_NAME, - HIGHLIGHT_EVENT_NAME, - HIGHLIGHT_SPAN_CLASS_NAME, - SESSION_STORAGE_KEY, -} 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, @@ -76,14 +71,6 @@ const useHighlight = ({ handleModalMessage, }: UseHighlightProps) => { const [editorAnswerMap, setEditorAnswerMap] = useState(makeInitialEditorAnswerMap(answerList)); - const storageKey = `${SESSION_STORAGE_KEY.editorAnswerMap}-${questionId}`; - - useEffect(() => { - const item = localStorage.getItem(storageKey); - if (item) { - setEditorAnswerMap(new Map(JSON.parse(item)) as EditorAnswerMap); - } - }, []); // span 클릭 시, 제공되는 형광펜 삭제 기능 타겟 const [longPressRemovalTarget, setLongPressRemovalTarget] = useState(null); @@ -92,8 +79,6 @@ const useHighlight = ({ const updateEditorAnswerMap = (newEditorAnswerMap: EditorAnswerMap) => { setEditorAnswerMap(newEditorAnswerMap); - // editorAnswerMap이 변경될 때 새로운 값을 로컬 스토리지에 저장 - localStorage.setItem(storageKey, JSON.stringify(Array.from(newEditorAnswerMap))); }; const resetHighlightMenu = () => { 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 56681e41e..d607901cc 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 { useMutation, useQueryClient } from '@tanstack/react-query'; import { postHighlight } from '@/apis/highlight'; -import { LOCAL_STORAGE_KEY } from '@/constants'; +import { LOCAL_STORAGE_KEY, REVIEW_QUERY_KEY, SESSION_STORAGE_KEY } from '@/constants'; import { EditorAnswerMap } from '@/types'; export interface UseMutateHighlightProps { @@ -17,6 +17,21 @@ const useMutateHighlight = ({ updateEditorAnswerMap, resetHighlightMenu, }: UseMutateHighlightProps) => { + const queryClient = useQueryClient(); + /** + * 형광펜 API 성공 후, 현재 질문에 대한 쿼리 캐시 무효화해서, 변경된 형광펜 데이터 불러오도록 함 + */ + const invalidateCurrentSectionQuery = () => { + const sectionId = sessionStorage.getItem(SESSION_STORAGE_KEY.currentReviewCollectionSectionId); + + if (sectionId) { + queryClient.invalidateQueries({ + predicate: (query) => + query.queryKey[0] === REVIEW_QUERY_KEY.groupedReviews && query.queryKey[1] === Number(sectionId), + }); + } + }; + const mutation = useMutation({ mutationFn: (newEditorAnswerMap: EditorAnswerMap) => postHighlight(newEditorAnswerMap, questionId), onMutate: () => { @@ -28,6 +43,8 @@ const useMutateHighlight = ({ // 토스트 모달 지우기 handleErrorModal(false); localStorage.removeItem(LOCAL_STORAGE_KEY.isHighlightError); + // 해당 질문 쿼리 캐시 무효화 + invalidateCurrentSectionQuery(); }, onError: (error) => { //토스트 모달 띄움 diff --git a/frontend/src/components/highlight/components/HighlightEditorContainer/index.tsx b/frontend/src/components/highlight/components/HighlightEditorContainer/index.tsx index a280fc534..ddfd7034f 100644 --- a/frontend/src/components/highlight/components/HighlightEditorContainer/index.tsx +++ b/frontend/src/components/highlight/components/HighlightEditorContainer/index.tsx @@ -32,7 +32,7 @@ const HighlightEditorContainer = (props: HighlightEditorContainerProps) => { theme.colors.white}; + border-top: 0.1rem solid ${({ theme }) => theme.colors.lightGray}; + ${media.xSmall} { flex-direction: column; gap: 0.2rem; diff --git a/frontend/src/components/layouts/Topbar/components/Logo/styles.ts b/frontend/src/components/layouts/Topbar/components/Logo/styles.ts index 682e0908d..860d6abea 100644 --- a/frontend/src/components/layouts/Topbar/components/Logo/styles.ts +++ b/frontend/src/components/layouts/Topbar/components/Logo/styles.ts @@ -3,17 +3,11 @@ import styled from '@emotion/styled'; import media from '@/utils/media'; export const Logo = styled.div` - line-height: 8rem; text-align: center; span { - font-size: 3rem; + font-size: 2.8rem; font-weight: ${({ theme }) => theme.fontWeight.bolder}; - letter-spacing: 0.7rem; - - ${media.small} { - font-size: 2.8rem; - } ${media.xSmall} { font-size: 2.6rem; diff --git a/frontend/src/components/layouts/Topbar/index.tsx b/frontend/src/components/layouts/Topbar/index.tsx index 61fd22b88..af4710013 100644 --- a/frontend/src/components/layouts/Topbar/index.tsx +++ b/frontend/src/components/layouts/Topbar/index.tsx @@ -1,11 +1,17 @@ +import { useLocation } from 'react-router'; + import UndraggableWrapper from '@/components/common/UndraggableWrapper'; +import { ROUTE } from '@/constants'; import Logo from './components/Logo'; import * as S from './styles'; const Topbar = () => { + const { pathname } = useLocation(); + const $hasNavigationTab = [ROUTE.reviewLinks, ROUTE.writtenReview].includes(pathname); + return ( - + diff --git a/frontend/src/components/layouts/Topbar/styles.ts b/frontend/src/components/layouts/Topbar/styles.ts index 01974ea6e..ea32b903b 100644 --- a/frontend/src/components/layouts/Topbar/styles.ts +++ b/frontend/src/components/layouts/Topbar/styles.ts @@ -1,6 +1,8 @@ import styled from '@emotion/styled'; -export const Layout = styled.section` +export const Layout = styled.section<{ $hasNavigationTab: boolean }>` + z-index: ${({ theme }) => theme.zIndex.topbar}; + display: flex; justify-content: space-between; @@ -9,7 +11,8 @@ export const Layout = styled.section` height: ${({ theme }) => theme.componentHeight.topbar}; padding: 2rem 2.5rem; - border-bottom: 0.1rem solid ${({ theme }) => theme.colors.lightGray}; + border-bottom: ${({ theme, $hasNavigationTab }) => + $hasNavigationTab ? `0.1rem solid ${theme.colors.lightGray}` : 'none'}; `; export const Container = styled.div` diff --git a/frontend/src/components/login/GithubLoginButton/index.tsx b/frontend/src/components/login/GithubLoginButton/index.tsx new file mode 100644 index 000000000..db6905b00 --- /dev/null +++ b/frontend/src/components/login/GithubLoginButton/index.tsx @@ -0,0 +1,21 @@ +import GithubWhiteLogoIcon from '@/assets/githubWhiteLogo.svg'; +import { LoginButton } from '@/components/login'; +import { LoginButtonStyleProps } from '@/components/login/LoginButton'; + +interface GithubLoginButtonProps extends LoginButtonStyleProps { + handleClick: () => void; +} + +const GithubLoginButton = ({ handleClick, $logoImgStyle, $buttonStyle }: GithubLoginButtonProps) => { + return ( + + ); +}; + +export default GithubLoginButton; diff --git a/frontend/src/components/login/LoginButton/index.tsx b/frontend/src/components/login/LoginButton/index.tsx new file mode 100644 index 000000000..366f055cc --- /dev/null +++ b/frontend/src/components/login/LoginButton/index.tsx @@ -0,0 +1,27 @@ +import { Button } from '@/components/index'; + +import * as S from './styles'; + +interface LoginButtonProps extends LoginButtonStyleProps { + platform: string; + logoSrc: string; + handleClick: () => void; +} + +export interface LoginButtonStyleProps { + $logoImgStyle?: React.CSSProperties; + $buttonStyle?: React.CSSProperties; +} + +const LoginButton = ({ platform, logoSrc, handleClick, $logoImgStyle, $buttonStyle }: LoginButtonProps) => { + return ( + + ); +}; + +export default LoginButton; diff --git a/frontend/src/components/login/LoginButton/styles.ts b/frontend/src/components/login/LoginButton/styles.ts new file mode 100644 index 000000000..b774a615e --- /dev/null +++ b/frontend/src/components/login/LoginButton/styles.ts @@ -0,0 +1,16 @@ +import styled from '@emotion/styled'; + +import { LoginButtonStyleProps } from './index'; + +export const ButtonLabelContainer = styled.div` + display: flex; + gap: 0.8rem; + align-items: center; +`; + +export const LogoImg = styled.img>` + width: 3.2rem; + height: 3.2rem; + + ${({ $logoImgStyle }) => $logoImgStyle && { ...$logoImgStyle }}; +`; diff --git a/frontend/src/components/login/LoginRequestModal/index.tsx b/frontend/src/components/login/LoginRequestModal/index.tsx new file mode 100644 index 000000000..bd518d200 --- /dev/null +++ b/frontend/src/components/login/LoginRequestModal/index.tsx @@ -0,0 +1,51 @@ +import { useTheme } from '@emotion/react'; +import { useState } from 'react'; + +import { ContentModal, GithubLoginButton } from '@/components'; + +import * as S from './styles'; + +const LOGIN_REQUEST_TITLE = { + loginIntent: '로그인하시겠어요?', + membershipCheck: '회원이신가요?', +} as const; + +type LoginRequestTitle = keyof typeof LOGIN_REQUEST_TITLE; + +interface LoginRequestModalProps { + titleType: LoginRequestTitle; + closeModal: () => void; +} + +const LoginRequestModal = ({ titleType, closeModal }: LoginRequestModalProps) => { + const [errorMessage, setErrorMessage] = useState(''); + const theme = useTheme(); + + // 에러 메세지 확인용. 추후 API 호출로 변경 + const handleClickLoginButton = () => { + setErrorMessage('에러 메세지'); + }; + + return ( + + + 로그인 후 간편하게 받은 리뷰를 확인하세요! + + {errorMessage && {errorMessage}} + + + ); +}; + +export default LoginRequestModal; diff --git a/frontend/src/components/login/LoginRequestModal/styles.ts b/frontend/src/components/login/LoginRequestModal/styles.ts new file mode 100644 index 000000000..674f69f21 --- /dev/null +++ b/frontend/src/components/login/LoginRequestModal/styles.ts @@ -0,0 +1,18 @@ +import styled from '@emotion/styled'; + +export const LoginRequestModal = styled.div` + display: flex; + flex-direction: column; + height: 9.5rem; +`; + +export const LoginRequestLabel = styled.p` + margin-bottom: 1rem; + font-size: 1.4rem; +`; + +export const ErrorMessage = styled.p` + margin-top: 0.6rem; + font-size: 1.2rem; + color: ${({ theme }) => theme.colors.red}; +`; diff --git a/frontend/src/components/login/index.ts b/frontend/src/components/login/index.ts new file mode 100644 index 000000000..1f41e25f0 --- /dev/null +++ b/frontend/src/components/login/index.ts @@ -0,0 +1,3 @@ +export { default as GithubLoginButton } from '../login/GithubLoginButton'; +export { default as LoginButton } from '../login/LoginButton'; +export { default as LoginRequestModal } from '../login/LoginRequestModal'; diff --git a/frontend/src/components/profile/ProfileInfo/index.tsx b/frontend/src/components/profile/ProfileInfo/index.tsx new file mode 100644 index 000000000..d9c0cdc21 --- /dev/null +++ b/frontend/src/components/profile/ProfileInfo/index.tsx @@ -0,0 +1,35 @@ +import DownArrowIcon from '@/assets/downArrow.svg'; +import UndraggableWrapper from '@/components/common/UndraggableWrapper'; +import { SocialType } from '@/types/profile'; + +import ProfileTab from '../ProfileTab'; +import useProfile from '../ProfileTab/hooks/useProfile'; + +import * as S from './styles'; + +interface ProfileInfoProps { + profileImageSrc?: string; + profileId: string; + socialType: SocialType; +} + +const ProfileInfo = ({ profileImageSrc, profileId, socialType }: ProfileInfoProps) => { + const { isOpened, containerRef, handleContainerClick } = useProfile(); + + return ( + + + + + {profileImageSrc && 프로필 사진} + + {profileId} + + + + {isOpened && } + + ); +}; + +export default ProfileInfo; diff --git a/frontend/src/components/profile/ProfileInfo/styles.ts b/frontend/src/components/profile/ProfileInfo/styles.ts new file mode 100644 index 000000000..c059f86b6 --- /dev/null +++ b/frontend/src/components/profile/ProfileInfo/styles.ts @@ -0,0 +1,57 @@ +import styled from '@emotion/styled'; + +import media from '@/utils/media'; + +interface DropdownStyleProps { + $isOpened: boolean; +} + +export const ProfileSection = styled.section` + cursor: pointer; + position: relative; + width: fit-content; +`; + +export const ProfileContainer = styled.div` + display: flex; + gap: 1rem; + align-items: center; + padding: 0 1rem; +`; + +export const ProfileImageWrapper = styled.div` + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + + width: 2.8rem; + height: 2.8rem; + + background-color: ${({ theme }) => theme.colors.gray}; + border-radius: 50%; + + ${media.small} { + width: 2.6rem; + height: 2.6rem; + } +`; + +export const ProfileId = styled.p` + font-weight: ${({ theme }) => theme.fontWeight.semibold}; + + ${media.small} { + display: none; + } +`; + +export const ArrowIcon = styled.img` + transform: ${({ $isOpened }) => ($isOpened ? 'rotate(180deg)' : 'rotate(0deg)')}; + width: 2rem; + height: 2rem; + transition: transform 0.3s ease-in-out; + + ${media.small} { + display: none; + } +`; diff --git a/frontend/src/components/profile/ProfileTab/components/ActionItem/index.tsx b/frontend/src/components/profile/ProfileTab/components/ActionItem/index.tsx new file mode 100644 index 000000000..5c21153a5 --- /dev/null +++ b/frontend/src/components/profile/ProfileTab/components/ActionItem/index.tsx @@ -0,0 +1,20 @@ +import { ProfileTabElementContent } from '@/types/profile'; + +import * as S from './styles'; + +interface ActionItemProps { + isDisplayedOnlyMobile: boolean; + content: ProfileTabElementContent; + handleItemClick: () => void; +} + +const ActionItem = ({ isDisplayedOnlyMobile, content, handleItemClick }: ActionItemProps) => { + return ( + + {content.icon.alt} + {content.text} + + ); +}; + +export default ActionItem; diff --git a/frontend/src/components/profile/ProfileTab/components/ActionItem/styles.ts b/frontend/src/components/profile/ProfileTab/components/ActionItem/styles.ts new file mode 100644 index 000000000..6026315d4 --- /dev/null +++ b/frontend/src/components/profile/ProfileTab/components/ActionItem/styles.ts @@ -0,0 +1,39 @@ +import styled from '@emotion/styled'; + +import media from '@/utils/media'; + +interface ActionItemStyleProps { + $isDisplayedOnlyMobile: boolean; +} + +export const ActionItemContainer = styled.div` + cursor: pointer; + + display: ${({ $isDisplayedOnlyMobile }) => ($isDisplayedOnlyMobile ? 'none' : 'flex')}; + gap: 1rem; + align-items: center; + + width: 100%; + height: 3rem; + padding: 1rem; + + border-radius: 0.8rem; + + :hover { + background-color: ${({ theme }) => theme.colors.lightGray}; + } + + ${media.small} { + display: ${({ $isDisplayedOnlyMobile }) => $isDisplayedOnlyMobile && 'flex'}; + } +`; + +export const ItemText = styled.p` + overflow: hidden; + display: block; + + width: 100%; + + text-overflow: ellipsis; + white-space: nowrap; +`; diff --git a/frontend/src/components/profile/ProfileTab/components/Divider/index.tsx b/frontend/src/components/profile/ProfileTab/components/Divider/index.tsx new file mode 100644 index 000000000..11198c0b4 --- /dev/null +++ b/frontend/src/components/profile/ProfileTab/components/Divider/index.tsx @@ -0,0 +1,11 @@ +import * as S from './styles'; + +interface DividerProps { + isDisplayedOnlyMobile: boolean; +} + +const Divider = ({ isDisplayedOnlyMobile }: DividerProps) => { + return ; +}; + +export default Divider; diff --git a/frontend/src/components/profile/ProfileTab/components/Divider/styles.ts b/frontend/src/components/profile/ProfileTab/components/Divider/styles.ts new file mode 100644 index 000000000..b4cc27451 --- /dev/null +++ b/frontend/src/components/profile/ProfileTab/components/Divider/styles.ts @@ -0,0 +1,22 @@ +import styled from '@emotion/styled'; + +import media from '@/utils/media'; + +interface DividerStyleProps { + $isDisplayedOnlyMobile: boolean; +} + +export const Divider = styled.hr` + display: ${({ $isDisplayedOnlyMobile }) => ($isDisplayedOnlyMobile ? 'none' : 'block')}; + + width: 100%; + height: 0; + margin: 0.5rem 0; + padding: 0; + + border: 0.1rem solid ${({ theme }) => theme.colors.placeholder}; + + ${media.small} { + display: ${({ $isDisplayedOnlyMobile }) => $isDisplayedOnlyMobile && 'block'}; + } +`; diff --git a/frontend/src/components/profile/ProfileTab/components/ReadonlyItem/index.tsx b/frontend/src/components/profile/ProfileTab/components/ReadonlyItem/index.tsx new file mode 100644 index 000000000..62920db7a --- /dev/null +++ b/frontend/src/components/profile/ProfileTab/components/ReadonlyItem/index.tsx @@ -0,0 +1,19 @@ +import { ProfileTabElementContent } from '@/types/profile'; + +import * as S from './styles'; + +interface ReadonlyItemProps { + isDisplayedOnlyMobile: boolean; + content: ProfileTabElementContent; +} + +const ReadonlyItem = ({ isDisplayedOnlyMobile, content }: ReadonlyItemProps) => { + return ( + + {content.icon.alt} + {content.text} + + ); +}; + +export default ReadonlyItem; diff --git a/frontend/src/components/profile/ProfileTab/components/ReadonlyItem/styles.ts b/frontend/src/components/profile/ProfileTab/components/ReadonlyItem/styles.ts new file mode 100644 index 000000000..cd5588922 --- /dev/null +++ b/frontend/src/components/profile/ProfileTab/components/ReadonlyItem/styles.ts @@ -0,0 +1,33 @@ +import styled from '@emotion/styled'; + +import media from '@/utils/media'; + +interface ReadonlyItemStyleProps { + $isDisplayedOnlyMobile: boolean; +} + +export const ReadonlyItemContainer = styled.div` + cursor: default; + + display: ${({ $isDisplayedOnlyMobile }) => ($isDisplayedOnlyMobile ? 'none' : 'flex')}; + gap: 1rem; + align-items: center; + + width: 100%; + height: 3rem; + padding: 1rem; + + ${media.small} { + display: ${({ $isDisplayedOnlyMobile }) => $isDisplayedOnlyMobile && 'flex'}; + } +`; + +export const ItemText = styled.p` + overflow: hidden; + display: block; + + width: 100%; + + text-overflow: ellipsis; + white-space: nowrap; +`; diff --git a/frontend/src/components/profile/ProfileTab/hooks/useProfile.tsx b/frontend/src/components/profile/ProfileTab/hooks/useProfile.tsx new file mode 100644 index 000000000..a96347ca8 --- /dev/null +++ b/frontend/src/components/profile/ProfileTab/hooks/useProfile.tsx @@ -0,0 +1,28 @@ +import { useEffect, useRef, useState } from 'react'; + +const useProfile = () => { + const [isOpened, setIsOpened] = useState(false); + const containerRef = useRef(null); + + const handleClickOutside = (event: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setIsOpened(false); + } + }; + + useEffect(() => { + document.addEventListener('mousedown', handleClickOutside); + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [containerRef]); + + const handleContainerClick = () => { + setIsOpened((prev) => !prev); + }; + + return { isOpened, containerRef, handleContainerClick }; +}; + +export default useProfile; diff --git a/frontend/src/components/profile/ProfileTab/hooks/useProfileTabElements.tsx b/frontend/src/components/profile/ProfileTab/hooks/useProfileTabElements.tsx new file mode 100644 index 000000000..1f50ed2a7 --- /dev/null +++ b/frontend/src/components/profile/ProfileTab/hooks/useProfileTabElements.tsx @@ -0,0 +1,81 @@ +import { useNavigate } from 'react-router'; + +import GitHubIcon from '@/assets/github.svg'; +import LogoutIcon from '@/assets/logout.svg'; +import MenuIcon from '@/assets/menu.svg'; +import OpenedBookIcon from '@/assets/openedBook.svg'; +import UserIcon from '@/assets/user.svg'; +import { ProfileTabElement, SocialType } from '@/types/profile'; + +interface UseProfileTabElementsProps { + profileId: string; + socialType: SocialType; +} + +const useProfileTabElements = ({ profileId, socialType }: UseProfileTabElementsProps) => { + const navigate = useNavigate(); + + const handleReviewLinkControl = () => { + // 리뷰 링크 관리 페이지로 이동 + console.log('리뷰 링크 관리 클릭'); + }; + + const handleCheckWrittenReviews = () => { + // 작성한 리뷰 확인 페이지로 이동 + console.log('작성한 리뷰 확인 클릭'); + }; + + const handleLogout = () => { + // 로그아웃 로직 + console.log('로그아웃 클릭'); + }; + + const profileTabElements: ProfileTabElement[] = [ + { + elementId: 'socialType', + elementType: 'readonly', + isDisplayedOnlyMobile: false, + content: + // 다른 소셜 타입 추가 시 리팩토링 + socialType === 'github' + ? { icon: { src: GitHubIcon, alt: '' }, text: 'GitHub 계정' } + : { icon: { src: '', alt: '' }, text: '' }, + }, + { + elementId: 'profileId', + elementType: 'readonly', + isDisplayedOnlyMobile: true, + content: { icon: { src: UserIcon, alt: '' }, text: profileId }, + }, + { + elementId: 'reviewLinkControlButton', + elementType: 'action', + isDisplayedOnlyMobile: false, + content: { icon: { src: MenuIcon, alt: '' }, text: '리뷰 링크 관리' }, + handleClick: handleReviewLinkControl, + }, + { + elementId: 'checkWrittenReviewsButton', + elementType: 'action', + isDisplayedOnlyMobile: false, + content: { icon: { src: OpenedBookIcon, alt: '' }, text: '작성한 리뷰 확인' }, + handleClick: handleCheckWrittenReviews, + }, + { + elementId: 'divider', + elementType: 'divider', + isDisplayedOnlyMobile: false, + }, + { + elementId: 'logoutButton', + elementType: 'action', + isDisplayedOnlyMobile: false, + content: { icon: { src: LogoutIcon, alt: '' }, text: '로그아웃' }, + handleClick: handleLogout, + }, + ]; + + return { profileTabElements }; +}; + +export default useProfileTabElements; diff --git a/frontend/src/components/profile/ProfileTab/index.tsx b/frontend/src/components/profile/ProfileTab/index.tsx new file mode 100644 index 000000000..5289ffb07 --- /dev/null +++ b/frontend/src/components/profile/ProfileTab/index.tsx @@ -0,0 +1,49 @@ +import UndraggableWrapper from '@/components/common/UndraggableWrapper'; +import { ProfileTabElement, SocialType } from '@/types/profile'; + +import ActionItem from './components/ActionItem'; +import Divider from './components/Divider'; +import ReadonlyItem from './components/ReadonlyItem'; +import useProfileTabElements from './hooks/useProfileTabElements'; +import * as S from './styles'; + +interface ProfileTabProps { + profileId: string; + socialType: SocialType; +} + +const ProfileTab = ({ socialType, profileId }: ProfileTabProps) => { + const { profileTabElements } = useProfileTabElements({ profileId, socialType }); + + const renderProfileTabItem = (item: ProfileTabElement) => { + switch (item.elementType) { + case 'readonly': + return ( + + ); + case 'action': + return ( + + ); + case 'divider': + return ; + } + }; + + return ( + + {profileTabElements.map((element) => renderProfileTabItem(element))} + + ); +}; + +export default ProfileTab; diff --git a/frontend/src/components/profile/ProfileTab/styles.ts b/frontend/src/components/profile/ProfileTab/styles.ts new file mode 100644 index 000000000..4e6b3650f --- /dev/null +++ b/frontend/src/components/profile/ProfileTab/styles.ts @@ -0,0 +1,22 @@ +import styled from '@emotion/styled'; + +export const ProfileTabContainer = styled.section` + position: absolute; + top: 5rem; + right: 0; + + display: flex; + flex-direction: column; + + width: max-content; + max-width: 25rem; + height: fit-content; + padding: 1rem; + + background-color: ${({ theme }) => theme.colors.white}; + border-radius: 0.8rem; + box-shadow: + 0 0.5rem 0.5rem -0.3rem rgba(0, 0, 0, 0.2), + 0 0.8rem 1rem 0.1rem rgba(0, 0, 0, 0.14), + 0 0.3rem 1.4rem 0.2rem rgba(0, 0, 0, 0.12); +`; diff --git a/frontend/src/pages/HomePage/components/ReviewZoneURLModal/index.tsx b/frontend/src/components/reviewURL/ReviewZoneURLModal/index.tsx similarity index 94% rename from frontend/src/pages/HomePage/components/ReviewZoneURLModal/index.tsx rename to frontend/src/components/reviewURL/ReviewZoneURLModal/index.tsx index 81d68ab2e..099632e5a 100644 --- a/frontend/src/pages/HomePage/components/ReviewZoneURLModal/index.tsx +++ b/frontend/src/components/reviewURL/ReviewZoneURLModal/index.tsx @@ -1,9 +1,6 @@ import { useEffect, useState } from 'react'; -import { AlertModal } from '@/components'; -import Checkbox from '@/components/common/Checkbox'; - -import { CopyTextButton } from '../index'; +import { AlertModal, CopyTextButton, Checkbox } from '@/components'; import * as S from './styles'; interface ReviewZoneURLModalProps { diff --git a/frontend/src/pages/HomePage/components/ReviewZoneURLModal/styles.ts b/frontend/src/components/reviewURL/ReviewZoneURLModal/styles.ts similarity index 100% rename from frontend/src/pages/HomePage/components/ReviewZoneURLModal/styles.ts rename to frontend/src/components/reviewURL/ReviewZoneURLModal/styles.ts diff --git a/frontend/src/pages/HomePage/components/Inputs/InputField.tsx b/frontend/src/components/reviewURL/URLGeneratorForm/components/InputField/index.tsx similarity index 81% rename from frontend/src/pages/HomePage/components/Inputs/InputField.tsx rename to frontend/src/components/reviewURL/URLGeneratorForm/components/InputField/index.tsx index 4be9564b2..8ad290cb9 100644 --- a/frontend/src/pages/HomePage/components/Inputs/InputField.tsx +++ b/frontend/src/components/reviewURL/URLGeneratorForm/components/InputField/index.tsx @@ -1,8 +1,6 @@ -import { Dispatch, SetStateAction } from 'react'; - import { EssentialPropsWithChildren } from '@/types'; -import * as S from '../URLGeneratorForm/styles'; +import * as S from './style'; interface InputFieldProps { id: string; @@ -14,7 +12,7 @@ interface InputFieldProps { export interface InputValueProps { id: string; value: string; - setValue: Dispatch>; + updateValue: (newValue: string) => void; } const InputField = ({ diff --git a/frontend/src/components/reviewURL/URLGeneratorForm/components/InputField/style.ts b/frontend/src/components/reviewURL/URLGeneratorForm/components/InputField/style.ts new file mode 100644 index 000000000..d7916390b --- /dev/null +++ b/frontend/src/components/reviewURL/URLGeneratorForm/components/InputField/style.ts @@ -0,0 +1,24 @@ +import styled from '@emotion/styled'; + +export const InputContainer = styled.div` + position: relative; + display: flex; + flex-direction: column; + gap: 0.2rem; +`; + +export const Label = styled.label` + margin-bottom: 1.2rem; +`; + +export const InputInfo = styled.p` + margin: 0.5rem 0.3rem 0.4rem; + font-size: 1.2rem; +`; + +export const ErrorMessage = styled.p` + height: 1.3rem; + padding-left: 0.7rem; + font-size: 1.3rem; + color: ${({ theme }) => theme.colors.red}; +`; diff --git a/frontend/src/pages/HomePage/components/Inputs/PasswordField.tsx b/frontend/src/components/reviewURL/URLGeneratorForm/components/PasswordField/index.tsx similarity index 58% rename from frontend/src/pages/HomePage/components/Inputs/PasswordField.tsx rename to frontend/src/components/reviewURL/URLGeneratorForm/components/PasswordField/index.tsx index a8bc9082d..c1a9fa4ec 100644 --- a/frontend/src/pages/HomePage/components/Inputs/PasswordField.tsx +++ b/frontend/src/components/reviewURL/URLGeneratorForm/components/PasswordField/index.tsx @@ -1,18 +1,19 @@ import { useEffect } from 'react'; import { EyeButton, Input } from '@/components'; +import { REVIEW_URL_GENERATOR_FORM_VALIDATION } from '@/constants'; import { useEyeButton, usePasswordValidation } from '@/hooks'; -import { MAX_PASSWORD_INPUT, MIN_PASSWORD_INPUT } from '@/pages/HomePage/utils/validateInput'; -import * as S from '../URLGeneratorForm/styles'; +import InputField, { InputValueProps } from '../InputField'; -import { InputValueProps } from './InputField'; +import * as S from './style'; -import { InputField } from '.'; - -const PasswordField = ({ id, value: password, setValue: setPassword }: InputValueProps) => { +const PasswordField = ({ id, value: password, updateValue: updatePassword }: InputValueProps) => { const { isOff, handleEyeButtonToggle } = useEyeButton(); - const { passwordErrorMessage, handlePasswordBlur, initializeIsBlurredOnce } = usePasswordValidation(password); + const { passwordErrorMessage, handlePasswordErrorMessage, handlePasswordBlur, initializeIsBlurredOnce } = + usePasswordValidation(password); + + const { min, max } = REVIEW_URL_GENERATOR_FORM_VALIDATION.password; useEffect(() => { initializeIsBlurredOnce(); @@ -22,7 +23,7 @@ const PasswordField = ({ id, value: password, setValue: setPassword }: InputValu @@ -33,7 +34,8 @@ const PasswordField = ({ id, value: password, setValue: setPassword }: InputValu type={isOff ? 'password' : 'text'} $style={{ width: '100%', paddingRight: '3rem' }} onChange={(event) => { - setPassword(event.target.value); + updatePassword(event.target.value); + handlePasswordErrorMessage(''); }} /> diff --git a/frontend/src/components/reviewURL/URLGeneratorForm/components/PasswordField/style.ts b/frontend/src/components/reviewURL/URLGeneratorForm/components/PasswordField/style.ts new file mode 100644 index 000000000..098f010e5 --- /dev/null +++ b/frontend/src/components/reviewURL/URLGeneratorForm/components/PasswordField/style.ts @@ -0,0 +1,6 @@ +import styled from '@emotion/styled'; + +export const PasswordInputContainer = styled.div` + position: relative; + display: flex; +`; diff --git a/frontend/src/components/reviewURL/URLGeneratorForm/components/ReviewGroupDataField.tsx b/frontend/src/components/reviewURL/URLGeneratorForm/components/ReviewGroupDataField.tsx new file mode 100644 index 000000000..898876f3f --- /dev/null +++ b/frontend/src/components/reviewURL/URLGeneratorForm/components/ReviewGroupDataField.tsx @@ -0,0 +1,42 @@ +import { useState } from 'react'; + +import { Input } from '@/components'; +import { REVIEW_URL_GENERATOR_FORM_VALIDATION } from '@/constants'; +import { isNotEmptyInput, isValidReviewGroupDataInput } from '@/utils'; + +import InputField, { InputValueProps } from './InputField'; + +const EMPTY_ERROR_MESSAGE = '공백이 아닌 내용을 입력해주세요'; +const { min, max } = REVIEW_URL_GENERATOR_FORM_VALIDATION.groupData; +const WRONG_LENGTH_ERROR_MESSAGE = `${min}자부터 ${max}자까지 입력할 수 있어요`; +interface ReviewGroupDataFieldProps extends InputValueProps { + labelText: string; +} +const ReviewGroupDataField = ({ id, labelText, value: data, updateValue: updateData }: ReviewGroupDataFieldProps) => { + const [errorMessage, setErrorMessage] = useState(''); + + const handleBlur = () => { + if (isValidReviewGroupDataInput(data)) return setErrorMessage(''); + // 공백으로만 이루어진 경우 + if (!isNotEmptyInput(data)) return setErrorMessage(EMPTY_ERROR_MESSAGE); + // 글자 수 초과 + setErrorMessage(WRONG_LENGTH_ERROR_MESSAGE); + }; + + return ( + + { + updateData(event.target.value); + setErrorMessage(''); + }} + onBlur={handleBlur} + /> + + ); +}; + +export default ReviewGroupDataField; diff --git a/frontend/src/components/reviewURL/URLGeneratorForm/components/URLGeneratorButton/index.tsx b/frontend/src/components/reviewURL/URLGeneratorForm/components/URLGeneratorButton/index.tsx new file mode 100644 index 000000000..56cdbd175 --- /dev/null +++ b/frontend/src/components/reviewURL/URLGeneratorForm/components/URLGeneratorButton/index.tsx @@ -0,0 +1,50 @@ +import { DataForReviewRequestCode } from '@/apis/group'; +import { Button } from '@/components'; +import { HOM_EVENT_NAME } from '@/constants'; +import { debounce, trackEventInAmplitude } from '@/utils'; + +import usePostDataForReviewRequestCode, { + UsePostDataForReviewRequestCodeProps, +} from '../../hooks/usePostDataForReviewRequestCode'; + +const DEBOUNCE_TIME = 300; + +interface URLGeneratorButtonProps extends UsePostDataForReviewRequestCodeProps { + isFormValid: boolean; + dataForReviewRequestCode: DataForReviewRequestCode; +} +const URLGeneratorButton = ({ + isFormValid, + dataForReviewRequestCode, + handleAPIError, + handleAPISuccess, +}: URLGeneratorButtonProps) => { + const mutation = usePostDataForReviewRequestCode({ handleAPIError, handleAPISuccess }); + + const postDataForURL = () => { + trackEventInAmplitude(HOM_EVENT_NAME.generateReviewURL); + + mutation.mutate(dataForReviewRequestCode, { + onSuccess: handleAPISuccess, + onError: handleAPIError, + }); + }; + + const handleURLCreationButtonClick = debounce((event: React.MouseEvent) => { + event.preventDefault(); + postDataForURL(); + }, DEBOUNCE_TIME); + + return ( + + ); +}; + +export default URLGeneratorButton; diff --git a/frontend/src/components/reviewURL/URLGeneratorForm/components/index.tsx b/frontend/src/components/reviewURL/URLGeneratorForm/components/index.tsx new file mode 100644 index 000000000..f02000a8b --- /dev/null +++ b/frontend/src/components/reviewURL/URLGeneratorForm/components/index.tsx @@ -0,0 +1,3 @@ +export { default as PasswordField } from './PasswordField'; +export { default as ReviewGroupDataField } from './ReviewGroupDataField'; +export { default as URLGeneratorButton } from './URLGeneratorButton'; diff --git a/frontend/src/pages/HomePage/hooks/usePostDataForReviewRequestCode/index.ts b/frontend/src/components/reviewURL/URLGeneratorForm/hooks/usePostDataForReviewRequestCode/index.ts similarity index 56% rename from frontend/src/pages/HomePage/hooks/usePostDataForReviewRequestCode/index.ts rename to frontend/src/components/reviewURL/URLGeneratorForm/hooks/usePostDataForReviewRequestCode/index.ts index 3dd6298bc..92f7a24f1 100644 --- a/frontend/src/pages/HomePage/hooks/usePostDataForReviewRequestCode/index.ts +++ b/frontend/src/components/reviewURL/URLGeneratorForm/hooks/usePostDataForReviewRequestCode/index.ts @@ -2,29 +2,32 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { DataForReviewRequestCode, postDataForReviewRequestCodeApi } from '@/apis/group'; import { GROUP_QUERY_KEY } from '@/constants'; +export interface UsePostDataForReviewRequestCodeProps { + handleAPISuccess: (data: any) => void; + handleAPIError: (error: Error) => void; +} -const usePostDataForReviewRequestCode = () => { +const usePostDataForReviewRequestCode = ({ + handleAPIError, + handleAPISuccess, +}: UsePostDataForReviewRequestCodeProps) => { const queryClient = useQueryClient(); - const { mutate, isSuccess, isPending, data } = useMutation({ + const mutation = useMutation({ mutationFn: (dataForReviewRequestCode: DataForReviewRequestCode) => postDataForReviewRequestCodeApi(dataForReviewRequestCode), + onMutate: () => { - if (isPending) return; + if (mutation.isPending) return; }, - onSuccess: () => { + onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: [GROUP_QUERY_KEY.dataForReviewRequestCode] }); + handleAPISuccess(data); }, - onError: (error) => { - console.error(error.message); - }, + onError: handleAPIError, }); - return { - mutate, - isSuccess, - data, - }; + return mutation; }; export default usePostDataForReviewRequestCode; diff --git a/frontend/src/pages/HomePage/hooks/usePostDataForReviewRequestCode/test.tsx b/frontend/src/components/reviewURL/URLGeneratorForm/hooks/usePostDataForReviewRequestCode/test.tsx similarity index 58% rename from frontend/src/pages/HomePage/hooks/usePostDataForReviewRequestCode/test.tsx rename to frontend/src/components/reviewURL/URLGeneratorForm/hooks/usePostDataForReviewRequestCode/test.tsx index 8956d47e7..81f889419 100644 --- a/frontend/src/pages/HomePage/hooks/usePostDataForReviewRequestCode/test.tsx +++ b/frontend/src/components/reviewURL/URLGeneratorForm/hooks/usePostDataForReviewRequestCode/test.tsx @@ -1,6 +1,6 @@ import { renderHook, act, waitFor } from '@testing-library/react'; -import { CREATED_REVIEW_REQUEST_CODE } from '@/mocks/mockData/group'; +import { VALID_REVIEW_GROUP_REVIEW_REQUEST_CODE } from '@/mocks/mockData/group'; import QueryClientWrapper from '@/queryTestSetup/QueryClientWrapper'; import usePostDataForReviewRequestCode from '.'; @@ -14,7 +14,16 @@ describe('usePostDataForReviewRequestCode', () => { groupAccessCode: '1234', }; - const { result } = renderHook(() => usePostDataForReviewRequestCode(), { wrapper: QueryClientWrapper }); + const { result } = renderHook( + () => + usePostDataForReviewRequestCode({ + handleAPIError: (error: Error) => { + console.error(error); + }, + handleAPISuccess: (data: any) => {}, + }), + { wrapper: QueryClientWrapper }, + ); // when act(() => { @@ -24,6 +33,6 @@ describe('usePostDataForReviewRequestCode', () => { await waitFor(() => expect(result.current.isSuccess).toBe(true)); // then - expect(result.current.data).toEqual(CREATED_REVIEW_REQUEST_CODE); + expect(result.current.data.reviewRequestCode).toEqual(VALID_REVIEW_GROUP_REVIEW_REQUEST_CODE); }); }); diff --git a/frontend/src/components/reviewURL/URLGeneratorForm/hooks/useURLGeneratorState.ts b/frontend/src/components/reviewURL/URLGeneratorForm/hooks/useURLGeneratorState.ts new file mode 100644 index 000000000..be108632b --- /dev/null +++ b/frontend/src/components/reviewURL/URLGeneratorForm/hooks/useURLGeneratorState.ts @@ -0,0 +1,39 @@ +import { useState } from 'react'; + +import { isValidPasswordInput, isValidReviewGroupDataInput } from '@/utils'; + +interface UseURLGeneratorStateProps { + isMember?: boolean; +} +const useURLGeneratorState = ({ isMember }: UseURLGeneratorStateProps) => { + const [revieweeName, setRevieweeName] = useState(''); + const [projectName, setProjectName] = useState(''); + const [password, setPassword] = useState(''); + + const isCommonFormValid = isValidReviewGroupDataInput(revieweeName) && isValidReviewGroupDataInput(projectName); + + const isFormValid = isMember ? isCommonFormValid : isCommonFormValid && isValidPasswordInput(password); + + const resetForm = () => { + setRevieweeName(''); + setProjectName(''); + !isMember && setPassword(''); + }; + + const urlGeneratorStateUpdater = { + revieweeName: (value: string) => setRevieweeName(value), + projectName: (value: string) => setProjectName(value), + password: (value: string) => setPassword(value), + }; + + return { + revieweeName, + projectName, + password, + isFormValid, + resetForm, + urlGeneratorStateUpdater, + }; +}; + +export default useURLGeneratorState; diff --git a/frontend/src/components/reviewURL/URLGeneratorForm/index.tsx b/frontend/src/components/reviewURL/URLGeneratorForm/index.tsx new file mode 100644 index 000000000..373d4c3d3 --- /dev/null +++ b/frontend/src/components/reviewURL/URLGeneratorForm/index.tsx @@ -0,0 +1,110 @@ +import { useId, useState } from 'react'; + +import AlertIcon from '@/assets/alertTriangle.svg'; +import { ErrorSuspenseContainer, ReviewZoneURLModal, Toast } from '@/components'; +import { ROUTE } from '@/constants/route'; +import { useModals } from '@/hooks'; + +import { PasswordField, ReviewGroupDataField, URLGeneratorButton } from './components'; +import useURLGeneratorState from './hooks/useURLGeneratorState'; +import * as S from './style'; + +const MODAL_KEYS = { + confirm: 'CONFIRM', +}; + +const TOAST_INFORM = { + icon: { src: AlertIcon, alt: '' }, + message: '리뷰 링크 생성에 실패했어요. 다시 시도해 보세요.', + duration: 1000 * 3, +}; +interface URLGeneratorFormProps { + isMember?: boolean; +} +const URLGeneratorForm = ({ isMember = false }: URLGeneratorFormProps) => { + const { revieweeName, projectName, password, isFormValid, resetForm, urlGeneratorStateUpdater } = + useURLGeneratorState({ isMember }); + + const [reviewZoneURL, setReviewZoneURL] = useState(''); + + const [isOpenToast, setIsOpenToast] = useState(false); + const { isOpen, openModal, closeModal } = useModals(); + + const handleOpenToast = (isOpen: boolean) => setIsOpenToast(isOpen); + + const useInputId = useId(); + + const INPUT_ID = { + revieweeName: `reviewee-name-input-${useInputId}`, + projectName: `project-name-input-${useInputId}`, + password: `password-input-${useInputId}`, + }; + + const getCompleteReviewZoneURL = (reviewRequestCode: string) => { + return `${window.location.origin}/${ROUTE.reviewZone}/${reviewRequestCode}`; + }; + + const handleAPISuccess = (data: any) => { + const completeReviewZoneURL = getCompleteReviewZoneURL(data.reviewRequestCode); + setReviewZoneURL(completeReviewZoneURL); + + resetForm(); + + handleOpenToast(false); + openModal(MODAL_KEYS.confirm); + }; + + const handleAPIError = (error: Error) => { + console.error(error.message); + + handleOpenToast(true); + closeModal(MODAL_KEYS.confirm); + }; + + return ( + + + 함께한 팀원으로부터 리뷰를 받아보세요! + + + + {!isMember && ( + + )} + + + + + + {isOpenToast && ( + + )} + {isOpen(MODAL_KEYS.confirm) && ( + closeModal(MODAL_KEYS.confirm)} /> + )} + + ); +}; + +export default URLGeneratorForm; diff --git a/frontend/src/components/reviewURL/URLGeneratorForm/style.ts b/frontend/src/components/reviewURL/URLGeneratorForm/style.ts new file mode 100644 index 000000000..bc2f9a677 --- /dev/null +++ b/frontend/src/components/reviewURL/URLGeneratorForm/style.ts @@ -0,0 +1,89 @@ +import styled from '@emotion/styled'; + +import media from '@/utils/media'; + +export const Title = styled.h2` + margin-bottom: 6.5rem; + font-size: 2.5rem; + white-space: nowrap; + + ${media.medium} { + font-size: 2rem; + } + + ${media.xSmall} { + margin-bottom: 4rem; + font-size: 1.8rem; + } + + ${media.xxSmall} { + font-size: 1.6rem; + } +`; + +export const Fieldset = styled.fieldset` + display: flex; + flex-direction: column; + gap: 1.8rem; + + width: 100%; + margin: 0; + padding: 0; + + border: none; +`; + +export const URLGeneratorForm = styled.form` + & > button { + width: 100%; + margin-top: 1.8rem; + } + + ${media.xSmall} { + label { + font-size: 1.5rem; + } + + p { + font-size: 1.3rem; + } + + & > button { + font-size: 1.5rem; + } + } + + ${media.xxSmall} { + label { + font-size: 1.3rem; + } + + p { + font-size: 1.1rem; + } + + & > button { + font-size: 1.3rem; + } + } +`; + +export const FormContainer = styled.section` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + width: 40%; + padding: 0px 9rem; + + ${media.medium} { + width: 45%; + padding: 0 9rem; + } + + ${media.small} { + width: 100%; + margin: 5rem 0 4rem 0; + } +`; diff --git a/frontend/src/components/reviewURL/index.ts b/frontend/src/components/reviewURL/index.ts new file mode 100644 index 000000000..54e30295d --- /dev/null +++ b/frontend/src/components/reviewURL/index.ts @@ -0,0 +1,2 @@ +export { default as URLGeneratorForm } from './URLGeneratorForm'; +export { default as ReviewZoneURLModal } from './ReviewZoneURLModal'; diff --git a/frontend/src/components/skeleton/ImgWithSkeleton/index.tsx b/frontend/src/components/skeleton/ImgWithSkeleton/index.tsx new file mode 100644 index 000000000..b89c74af3 --- /dev/null +++ b/frontend/src/components/skeleton/ImgWithSkeleton/index.tsx @@ -0,0 +1,33 @@ +import React, { useState } from 'react'; + +import * as S from './style'; + +interface ImgWithSkeletonProps { + children: React.ReactElement>; + imgWidth: string; + imgHeight: string; +} + +const ImgWithSkeleton = ({ children, imgWidth, imgHeight }: ImgWithSkeletonProps) => { + const [isLoaded, setIsLoaded] = useState(false); + + const handleImgLoad = (event: React.SyntheticEvent) => { + if (children.props.onLoad) { + children.props.onLoad(event); + } + setIsLoaded(true); + }; + + return ( + + {!isLoaded && } + + {React.cloneElement(children, { + onLoad: (event: React.SyntheticEvent) => handleImgLoad(event), + })} + + + ); +}; + +export default ImgWithSkeleton; diff --git a/frontend/src/components/skeleton/ImgWithSkeleton/style.ts b/frontend/src/components/skeleton/ImgWithSkeleton/style.ts new file mode 100644 index 000000000..5c0f5c372 --- /dev/null +++ b/frontend/src/components/skeleton/ImgWithSkeleton/style.ts @@ -0,0 +1,47 @@ +import styled from '@emotion/styled'; + +interface ContainerProps { + $width: string; + $height: string; +} +export const Container = styled.div` + position: relative; + width: ${(props) => props.$width}; + height: ${(props) => props.$height}; +`; +export const ImgWrapper = styled.div<{ $isLoaded: boolean }>` + position: absolute; + top: 0; + left: 0; + + width: 100%; + height: 100%; + + opacity: ${(props) => (props.$isLoaded ? 1 : 0)}; + + transition: opacity 300ms; +`; +export const ImgSkeleton = styled.div` + width: 100%; + height: 100%; + + background-image: linear-gradient( + 135deg, + ${({ theme }) => theme.colors.lightGray} 40%, + rgba(246, 246, 246, 0.89) 50%, + ${({ theme }) => theme.colors.lightGray} 85% + ); + background-size: 200% 100%; + border-radius: ${({ theme }) => theme.borderRadius.basic}; + + animation: skeleton-animation 1.5s infinite linear; + + @keyframes skeleton-animation { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } + } +`; diff --git a/frontend/src/components/skeleton/index.tsx b/frontend/src/components/skeleton/index.tsx new file mode 100644 index 000000000..08e501619 --- /dev/null +++ b/frontend/src/components/skeleton/index.tsx @@ -0,0 +1 @@ +export { default as ImgWithSkeleton } from './ImgWithSkeleton'; diff --git a/frontend/src/constants/amplitudeEventName.ts b/frontend/src/constants/amplitudeEventName.ts index c610d53b7..a9b406408 100644 --- a/frontend/src/constants/amplitudeEventName.ts +++ b/frontend/src/constants/amplitudeEventName.ts @@ -24,6 +24,8 @@ export const PAGE_VISITED_EVENT_NAME: { [key in Exclude]: s detailedReview: '[page] 리뷰 상세 보기 페이지', reviewWriting: '[page] 리뷰 작성 페이지', reviewWritingComplete: '[page] 리뷰 작성 완료 페이지', + reviewLinks: '[page] 리뷰 링크 관리 페이지', + writtenReview: '[page] 작성한 리뷰 확인 페이지', }; export const REVIEW_WRITING_EVENT_NAME = { diff --git a/frontend/src/constants/index.ts b/frontend/src/constants/index.ts index 4c7bf0c45..ce2f556f5 100644 --- a/frontend/src/constants/index.ts +++ b/frontend/src/constants/index.ts @@ -6,3 +6,4 @@ export * from './routerParam'; export * from './highlight'; export * from './storageKey'; export * from './amplitudeEventName'; +export * from './route'; diff --git a/frontend/src/constants/review.ts b/frontend/src/constants/review.ts index 8535db266..a32c70a40 100644 --- a/frontend/src/constants/review.ts +++ b/frontend/src/constants/review.ts @@ -13,3 +13,8 @@ export const REVIEW_EMPTY = { noReviewInTotal: '아직 받은 리뷰가 없어요!', noReviewInQuestion: '이 질문은 아직 받은 답변이 없어요!', }; + +export const REVIEW_URL_GENERATOR_FORM_VALIDATION = { + groupData: { min: 1, max: 50 }, + password: { min: 4, max: 20 }, +}; diff --git a/frontend/src/constants/route.ts b/frontend/src/constants/route.ts index 0826e9ff8..b8b220cfa 100644 --- a/frontend/src/constants/route.ts +++ b/frontend/src/constants/route.ts @@ -7,4 +7,6 @@ export const ROUTE = { detailedReview: 'user/detailed-review', reviewZone: 'user/review-zone', reviewCollection: 'user/review-collection', + reviewLinks: 'user/review-links', + writtenReview: 'user/written-review', }; diff --git a/frontend/src/constants/storageKey.ts b/frontend/src/constants/storageKey.ts index f25f48312..5fb04d984 100644 --- a/frontend/src/constants/storageKey.ts +++ b/frontend/src/constants/storageKey.ts @@ -4,5 +4,5 @@ export const LOCAL_STORAGE_KEY = { }; export const SESSION_STORAGE_KEY = { - editorAnswerMap: 'editorAnswerMap-question', + currentReviewCollectionSectionId: 'currentReviewCollectionSectionId', }; diff --git a/frontend/src/hooks/useNavigationTabs.ts b/frontend/src/hooks/useNavigationTabs.ts new file mode 100644 index 000000000..57f416023 --- /dev/null +++ b/frontend/src/hooks/useNavigationTabs.ts @@ -0,0 +1,35 @@ +import { useLocation, useNavigate } from 'react-router'; + +import { ROUTE } from '@/constants'; + +const useNavigationTabs = () => { + const { pathname } = useLocation(); + const navigate = useNavigate(); + + const navigateReviewLinkManagementPage = () => { + navigate(`/${ROUTE.reviewLinks}`); + }; + + const navigateWrittenReviewConfirmPage = () => { + navigate(`/${ROUTE.writtenReview}`); + }; + + const tabList = [ + { + label: '리뷰 링크 관리', + path: `/${ROUTE.reviewLinks}`, + handleTabClick: navigateReviewLinkManagementPage, + }, + { + label: '작성한 리뷰 확인', + path: `/${ROUTE.writtenReview}`, + handleTabClick: navigateWrittenReviewConfirmPage, + }, + ]; + + const currentTabIndex = tabList.findIndex((tab) => tab.path === pathname); + + return { currentTabIndex, tabList }; +}; + +export default useNavigationTabs; diff --git a/frontend/src/hooks/usePasswordValidation.ts b/frontend/src/hooks/usePasswordValidation.ts index 2759ff345..3a17a8c0e 100644 --- a/frontend/src/hooks/usePasswordValidation.ts +++ b/frontend/src/hooks/usePasswordValidation.ts @@ -1,14 +1,12 @@ import { useState, useEffect } from 'react'; -import { - isWithinLengthRange, - isAlphanumeric, - MAX_PASSWORD_INPUT, - MIN_PASSWORD_INPUT, -} from '@/pages/HomePage/utils/validateInput'; +import { REVIEW_URL_GENERATOR_FORM_VALIDATION } from '@/constants'; +import { isWithinLengthRange, isAlphanumeric } from '@/utils'; + +const { min, max } = REVIEW_URL_GENERATOR_FORM_VALIDATION.password; const INVALID_CHAR_ERROR_MESSAGE = `영문(대/소문자) 및 숫자만 입력해주세요`; -const PASSWORD_LENGTH_ERROR_MESSAGE = `${MIN_PASSWORD_INPUT}자부터 ${MAX_PASSWORD_INPUT}자까지 입력할 수 있어요`; +const PASSWORD_LENGTH_ERROR_MESSAGE = `${min}자부터 ${max}자까지 입력할 수 있어요`; const usePasswordValidation = (password: string) => { const [passwordErrorMessage, setPasswordErrorMessage] = useState(''); @@ -19,7 +17,7 @@ const usePasswordValidation = (password: string) => { }; const validatePassword = () => { - if (!isWithinLengthRange(password, MAX_PASSWORD_INPUT, MIN_PASSWORD_INPUT)) { + if (!isWithinLengthRange(password, max, min)) { return setPasswordErrorMessage(PASSWORD_LENGTH_ERROR_MESSAGE); } if (!isAlphanumeric(password)) { @@ -33,12 +31,15 @@ const usePasswordValidation = (password: string) => { validatePassword(); }; + const handlePasswordErrorMessage = (errorMessage: string) => setPasswordErrorMessage(errorMessage); + useEffect(() => { if (isBlurredOnce) validatePassword(); }, [password, isBlurredOnce]); return { passwordErrorMessage, + handlePasswordErrorMessage, handlePasswordBlur, initializeIsBlurredOnce, }; diff --git a/frontend/src/hooks/useSearchParamAndQuery.ts b/frontend/src/hooks/useSearchParamAndQuery.ts index 349d9a6e5..898f1942f 100644 --- a/frontend/src/hooks/useSearchParamAndQuery.ts +++ b/frontend/src/hooks/useSearchParamAndQuery.ts @@ -1,13 +1,13 @@ import { useLocation, useParams } from 'react-router'; interface UseSearchParamAndQueryProps { - paramKey: string; + paramKey?: string; queryStringKey?: string; } /** * url에서 원하는 param, queryString의 값을 가져온다. * @param paramKey: 가져오고 싶은 param의 key - * @param queryStringKey: 가져오고 싶은 queryString의 key (옵셔널) + * @param queryStringKey: 가져오고 싶은 queryString의 key */ const useSearchParamAndQuery = ({ paramKey, queryStringKey }: UseSearchParamAndQueryProps) => { const location = useLocation(); diff --git a/frontend/src/mocks/handlers/group.ts b/frontend/src/mocks/handlers/group.ts index 50e12b17b..ea4098818 100644 --- a/frontend/src/mocks/handlers/group.ts +++ b/frontend/src/mocks/handlers/group.ts @@ -4,7 +4,6 @@ import endPoint, { REVIEW_GROUP_DATA_API_PARAMS, REVIEW_GROUP_DATA_API_URL } fro import { API_ERROR_MESSAGE, INVALID_REVIEW_PASSWORD_MESSAGE } from '@/constants'; import { - CREATED_REVIEW_REQUEST_CODE, MOCK_AUTH_TOKEN_NAME, REVIEW_GROUP_DATA, VALID_REVIEW_GROUP_REVIEW_REQUEST_CODE, @@ -14,17 +13,10 @@ import { // NOTE: reviewRequestCode 생성 정상 응답 const postDataForReviewRequestCode = () => { return http.post(endPoint.postingDataForReviewRequestCode, async () => { - return HttpResponse.json(CREATED_REVIEW_REQUEST_CODE, { status: 200 }); + return HttpResponse.json({ reviewRequestCode: VALID_REVIEW_GROUP_REVIEW_REQUEST_CODE }, { status: 200 }); }); }; -// NOTE: reviewRequestCode 생성 에러 응답 -// const postDataForReviewRequestCode = () => { -// return http.post(endPoint.postingDataForReviewRequestCode, async () => { -// return HttpResponse.json({ error: '서버 에러 테스트' }, { status: 500 }); -// }); -// }; - const postPassWordValidation = () => { return http.post(endPoint.checkingPassword, async ({ request, cookies }) => { // request body의 존재 검증 diff --git a/frontend/src/mocks/mockData/detailedReviewMockData.ts b/frontend/src/mocks/mockData/detailedReviewMockData.ts index 919428f5c..db8e92858 100644 --- a/frontend/src/mocks/mockData/detailedReviewMockData.ts +++ b/frontend/src/mocks/mockData/detailedReviewMockData.ts @@ -26,8 +26,16 @@ export const DETAILED_REVIEW_MOCK_DATA: DetailReviewData = { minCount: 1, maxCount: 2, options: [ - { optionId: 1, content: '🗣️ 커뮤니케이션, 협업 능력', isChecked: true }, - { optionId: 2, content: '💡 문제 해결 능력', isChecked: false }, + { + optionId: 1, + content: '🗣️커뮤니케이션, 협업 능력 (예: 팀원간의 원활한 정보 공유, 명확한 의사소통)', + isChecked: true, + }, + { + optionId: 2, + content: '💡문제 해결 능력 (예: 프로젝트 중 만난 버그/오류를 분석하고 이를 해결하는 능력)', + isChecked: true, + }, ], }, }, diff --git a/frontend/src/mocks/mockData/group.ts b/frontend/src/mocks/mockData/group.ts index caa6ca444..5ead4de84 100644 --- a/frontend/src/mocks/mockData/group.ts +++ b/frontend/src/mocks/mockData/group.ts @@ -1,9 +1,5 @@ import { ReviewGroupData } from '@/types'; -export const CREATED_REVIEW_REQUEST_CODE = { - reviewRequestCode: 'mocked-reviewRequestCode', -}; - export const VALIDATED_PASSWORD = '1234'; export const MOCK_AUTH_TOKEN_NAME = 'mockAuthToken'; diff --git a/frontend/src/mocks/mockData/reviewCollection.ts b/frontend/src/mocks/mockData/reviewCollection.ts index c2946f3bf..4b8b5df43 100644 --- a/frontend/src/mocks/mockData/reviewCollection.ts +++ b/frontend/src/mocks/mockData/reviewCollection.ts @@ -115,7 +115,7 @@ export const GROUPED_REVIEWS_MOCK_DATA: GroupedReviews[] = [ { id: 2, content: - '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, @@ -132,13 +132,13 @@ export const GROUPED_REVIEWS_MOCK_DATA: GroupedReviews[] = [ { id: 3, content: - '장의 시작부분은 짧고 직접적이며, 뒤따라 나올 복잡한 정보를 어떻게 해석해야 할 것인지 프레임을 짜주는 역할을 해야 한다. 그러면 아무리 긴 문장이라도 쉽게 읽힌다.', + '문제의 핵심 원인을 빠르게 파악하고, 이를 바탕으로 실행 가능한 솔루션을 제시하며 팀의 목표를 달성하는 데 큰 기여를 했습니다. 특히, 예상치 못한 상황에서도 냉철한 판단과 적극적인 태도로 해결책을 찾아가는 모습은 팀원들에게 좋은 자극이 되었습니다.', highlights: [], }, { id: 4, content: - '고액공제건강보험과 건강저축계좌를 만들어 노동자와 고용주가 세금공제를 받을 수 있도록 하면 결과적으로 노동자의 의료보험 부담이 커진다. 세금공제를 받을 수 있도록 하면------------------------------------------- 결과적으로 노동자의 의료보험 부담이 커진다.', + '문제를 다양한 관점에서 바라보며 가장 적합한 해결책을 찾아내는 능력이 뛰어납니다. 특히, 여러 이해관계자 간의 의견을 조율하며 모두가 만족할 수 있는 방안을 제안한 점이 돋보였습니다. 이 과정에서 보여준 적극적인 소통과 논리적인 접근법은 팀의 신뢰를 더욱 높였고, 어려운 과제를 성공적으로 마무리할 수 있는 원동력이 되었습니다.', highlights: [], }, ], @@ -181,7 +181,7 @@ export const GROUPED_REVIEWS_MOCK_DATA: GroupedReviews[] = [ { id: 2, content: - '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, @@ -198,13 +198,13 @@ export const GROUPED_REVIEWS_MOCK_DATA: GroupedReviews[] = [ { id: 3, content: - '장의 시작부분은 짧고 직접적이며, 뒤따라 나올 복잡한 정보를 어떻게 해석해야 할 것인지 프레임을 짜주는 역할을 해야 한다. 그러면 아무리 긴 문장이라도 쉽게 읽힌다.', + '시간을 효율적으로 활용하는 뛰어난 능력을 보여주셨습니다. 작업 초기부터 명확한 계획을 수립하고 이를 끝까지 유지하는 모습이 인상적이었으며, 예상치 못한 변수에도 침착하게 대처하며 프로젝트의 일정과 품질을 모두 충족시켰습니다. 이러한 점은 팀에 큰 안정감을 주었고, 함께 일하는 사람들에게도 좋은 본보기가 되었습니다.', highlights: [], }, { id: 4, content: - '고액공제건강보험과 건강저축계좌를 만들어 노동자와 고용주가 세금공제를 받을 수 있도록 하면 결과적으로 노동자의 의료보험 부담이 커진다. 세금공제를 받을 수 있도록 하면------------------------------------------- 결과적으로 노동자의 의료보험 부담이 커진다.', + '타이트한 일정 속에서도 주어진 목표를 체계적으로 달성하며, 동시에 세부적인 디테일까지 놓치지 않는 모습을 보여주셨습니다. 특히, 작업 과정에서 우선순위를 명확히 설정하고, 불필요한 시간 낭비를 줄이는 효율적인 접근 방식은 팀의 전반적인 속도와 성과에 크게 기여했습니다. 앞으로도 이런 시간 관리 능력을 통해 더 많은 성과를 이루시리라 믿습니다.', highlights: [], }, ], diff --git a/frontend/src/mocks/mockData/reviewListMockData.ts b/frontend/src/mocks/mockData/reviewListMockData.ts index 8f9986c3d..7d73abd0c 100644 --- a/frontend/src/mocks/mockData/reviewListMockData.ts +++ b/frontend/src/mocks/mockData/reviewListMockData.ts @@ -10,8 +10,12 @@ export const REVIEW_LIST: ReviewList = { createdAt: '2024-07-24', contentPreview: `1. 나는 짧은 데이터`, categories: [ - { optionId: 1, content: '🗣️ 커뮤니케이션, 협업 능력' }, - { optionId: 5, content: '🌱 성장 마인드셋' }, + { optionId: 1, content: '🗣️커뮤니케이션, 협업 능력 (예: 팀원간의 원활한 정보 공유, 명확한 의사소통)' }, + { + optionId: 5, + content: + '🌱성장 마인드셋 (예: 새로운 분야나 잘 모르는 분야에 도전하는 마음, 꾸준한 노력으로 프로젝트 이전보다 성장하는 모습)', + }, ], }, { @@ -19,8 +23,8 @@ export const REVIEW_LIST: ReviewList = { createdAt: '2023-08-29', contentPreview: `2. 전해주고 싶어 슬픈 시간이 다 흩어진 후에야 들리지만 눈을 감고 느껴봐 움직이는 마음 너를 향한 내 눈빛을 특별한 기적을 기다리지마 눈 앞에선 우리의 거친 길은 알 수 없는 미래와 벽 바꾸지 않아 포기할 수 없어 변치 않을 사랑으로 지켜줘 상처 입은 내 맘까지 시선 속에서 말은 필요 없어 멈춰져 버린 이 시간 사랑해 널 이 느낌 이대로 그려왔던 헤매임의 끝 이 세상 속에서 반복되는 슬픔 이젠 안녕 수많은 알 수 없는 길 속에 희미한 빛을 난 쫓아가 언제까지라도 함께 하는거야 다시 만난 나의 세계`, categories: [ - { optionId: 3, content: '⏰ 시간 관리 능력' }, - { optionId: 4, content: '🤓 기술적 역량, 전문 지식' }, + { optionId: 3, content: '⏰시간 관리 능력 (예: 일정과 마감 기한 준수, 업무의 우선 순위 분배)' }, + { optionId: 4, content: '💻기술적 역량, 전문 지식 (예: 요구 사항을 이해하고 이를 구현하는 능력)' }, ], }, { @@ -41,8 +45,12 @@ export const REVIEW_LIST: ReviewList = { Disco overload I'm into that I'm good to go `, categories: [ - { optionId: 5, content: '🌱 성장 마인드셋' }, - { optionId: 1, content: '🗣️ 커뮤니케이션, 협업 능력' }, + { + optionId: 5, + content: + '🌱성장 마인드셋 (예: 새로운 분야나 잘 모르는 분야에 도전하는 마음, 꾸준한 노력으로 프로젝트 이전보다 성장하는 모습)', + }, + { optionId: 1, content: '🗣️커뮤니케이션, 협업 능력 (예: 팀원간의 원활한 정보 공유, 명확한 의사소통)' }, ], }, { @@ -61,8 +69,8 @@ export const REVIEW_LIST: ReviewList = { 아무 걱정도 하지는 마, 나에게 다 맡겨 봐 `, categories: [ - { optionId: 1, content: '🗣️ 커뮤니케이션, 협업 능력' }, - { optionId: 2, content: '💡 문제 해결 능력' }, + { optionId: 1, content: '🗣️커뮤니케이션, 협업 능력 (예: 팀원간의 원활한 정보 공유, 명확한 의사소통)' }, + { optionId: 2, content: '💡문제 해결 능력 (예: 프로젝트 중 만난 버그/오류를 분석하고 이를 해결하는 능력)' }, ], }, { @@ -80,8 +88,8 @@ export const REVIEW_LIST: ReviewList = { That tick, that tick, tick bomb `, categories: [ - { optionId: 1, content: '🗣️ 커뮤니케이션, 협업 능력' }, - { optionId: 2, content: '💡 문제 해결 능력' }, + { optionId: 1, content: '🗣️커뮤니케이션, 협업 능력 (예: 팀원간의 원활한 정보 공유, 명확한 의사소통)' }, + { optionId: 2, content: '💡문제 해결 능력 (예: 프로젝트 중 만난 버그/오류를 분석하고 이를 해결하는 능력)' }, ], }, { @@ -95,8 +103,12 @@ export const REVIEW_LIST: ReviewList = { Ooh-ooh, ooh-ooh, lalalala-lalala `, categories: [ - { optionId: 5, content: '🌱 성장 마인드셋' }, - { optionId: 1, content: '🗣️ 커뮤니케이션, 협업 능력' }, + { + optionId: 5, + content: + '🌱성장 마인드셋 (예: 새로운 분야나 잘 모르는 분야에 도전하는 마음, 꾸준한 노력으로 프로젝트 이전보다 성장하는 모습)', + }, + { optionId: 1, content: '🗣️커뮤니케이션, 협업 능력 (예: 팀원간의 원활한 정보 공유, 명확한 의사소통)' }, ], }, { @@ -104,8 +116,8 @@ export const REVIEW_LIST: ReviewList = { createdAt: '2021-08-01', contentPreview: `7. 나는 짧은 데이터`, categories: [ - { optionId: 3, content: '⏰ 시간 관리 능력' }, - { optionId: 2, content: '💡 문제 해결 능력' }, + { optionId: 3, content: '⏰시간 관리 능력 (예: 일정과 마감 기한 준수, 업무의 우선 순위 분배)' }, + { optionId: 2, content: '💡문제 해결 능력 (예: 프로젝트 중 만난 버그/오류를 분석하고 이를 해결하는 능력)' }, ], }, { @@ -113,8 +125,12 @@ export const REVIEW_LIST: ReviewList = { createdAt: '2021-08-01', contentPreview: `8. 공간을 한 눈에, 예약은 한 번에! 맞춤형 공간예약 서비스 제작 플랫폼 찜꽁입니다! 공간 제공자(관리자)는 에디터를 통해 공간을 생성할 수 있습니다! 생성한 공간은 링크를 통해 사용자에게 제공할 수 있으며, 사용자는 링크를 통해 간편하게 공간을 확인하고 예약을...`, categories: [ - { optionId: 4, content: '🤓 기술적 역량, 전문 지식' }, - { optionId: 5, content: '🌱 성장 마인드셋' }, + { optionId: 4, content: '💻기술적 역량, 전문 지식 (예: 요구 사항을 이해하고 이를 구현하는 능력)' }, + { + optionId: 5, + content: + '🌱성장 마인드셋 (예: 새로운 분야나 잘 모르는 분야에 도전하는 마음, 꾸준한 노력으로 프로젝트 이전보다 성장하는 모습)', + }, ], }, { @@ -122,8 +138,12 @@ export const REVIEW_LIST: ReviewList = { createdAt: '2021-08-01', contentPreview: `9. 공간을 한 눈에, 예약은 한 번에! 맞춤형 공간예약 서비스 제작 플랫폼 찜꽁입니다! 공간 제공자(관리자)는 에디터를 통해 공간을 생성할 수 있습니다! 생성한 공간은 링크를 통해 사용자에게 제공할 수 있으며, 사용자는 링크를 통해 간편하게 공간을 확인하고 예약을...`, categories: [ - { optionId: 5, content: '🌱 성장 마인드셋' }, - { optionId: 2, content: '💡 문제 해결 능력' }, + { + optionId: 5, + content: + '🌱성장 마인드셋 (예: 새로운 분야나 잘 모르는 분야에 도전하는 마음, 꾸준한 노력으로 프로젝트 이전보다 성장하는 모습)', + }, + { optionId: 2, content: '💡문제 해결 능력 (예: 프로젝트 중 만난 버그/오류를 분석하고 이를 해결하는 능력)' }, ], }, { @@ -131,8 +151,8 @@ export const REVIEW_LIST: ReviewList = { createdAt: '2021-08-01', contentPreview: `10. 공간을 한 눈에, 예약은 한 번에! 맞춤형 공간예약 서비스 제작 플랫폼 찜꽁입니다! 공간 제공자(관리자)는 에디터를 통해 공간을 생성할 수 있습니다! 생성한 공간은 링크를 통해 사용자에게 제공할 수 있으며, 사용자는 링크를 통해 간편하게 공간을 확인하고 예약을...`, categories: [ - { optionId: 1, content: '🗣️ 커뮤니케이션, 협업 능력' }, - { optionId: 3, content: '⏰ 시간 관리 능력' }, + { optionId: 1, content: '🗣️커뮤니케이션, 협업 능력 (예: 팀원간의 원활한 정보 공유, 명확한 의사소통)' }, + { optionId: 3, content: '⏰시간 관리 능력 (예: 일정과 마감 기한 준수, 업무의 우선 순위 분배)' }, ], }, { @@ -140,8 +160,8 @@ export const REVIEW_LIST: ReviewList = { createdAt: '2021-08-01', contentPreview: `11. 공간을 한 눈에, 예약은 한 번에! 맞춤형 공간예약 서비스 제작 플랫폼 찜꽁입니다! 공간 제공자(관리자)는 에디터를 통해 공간을 생성할 수 있습니다! 생성한 공간은 링크를 통해 사용자에게 제공할 수 있으며, 사용자는 링크를 통해 간편하게 공간을 확인하고 예약을...`, categories: [ - { optionId: 1, content: '🗣️ 커뮤니케이션, 협업 능력' }, - { optionId: 2, content: '💡 문제 해결 능력' }, + { optionId: 1, content: '🗣️커뮤니케이션, 협업 능력 (예: 팀원간의 원활한 정보 공유, 명확한 의사소통)' }, + { optionId: 2, content: '💡문제 해결 능력 (예: 프로젝트 중 만난 버그/오류를 분석하고 이를 해결하는 능력)' }, ], }, { @@ -149,8 +169,12 @@ export const REVIEW_LIST: ReviewList = { createdAt: '2021-08-01', contentPreview: `12. 공간을 한 눈에, 예약은 한 번에! 맞춤형 공간예약 서비스 제작 플랫폼 찜꽁입니다! 공간 제공자(관리자)는 에디터를 통해 공간을 생성할 수 있습니다! 생성한 공간은 링크를 통해 사용자에게 제공할 수 있으며, 사용자는 링크를 통해 간편하게 공간을 확인하고 예약을...`, categories: [ - { optionId: 4, content: '🌱 성장 마인드셋' }, - { optionId: 1, content: '🗣️ 커뮤니케이션, 협업 능력' }, + { + optionId: 4, + content: + '🌱성장 마인드셋 (예: 새로운 분야나 잘 모르는 분야에 도전하는 마음, 꾸준한 노력으로 프로젝트 이전보다 성장하는 모습)', + }, + { optionId: 1, content: '🗣️커뮤니케이션, 협업 능력 (예: 팀원간의 원활한 정보 공유, 명확한 의사소통)' }, ], }, { @@ -158,8 +182,12 @@ export const REVIEW_LIST: ReviewList = { createdAt: '2024-07-24', contentPreview: `13. 물론 시중에 출간되어 있는 책들로 공부하는 것도 큰 장점이지만 더 깊은 공부를 하고 싶을 때 공식 문서를 확인해보는 것이 좋기 때문에, 저 개인적인 생각으로는 언어 공부를 아예 처음 입문하시는 분들은 한국에서 출간된 개발 서적으로 공부를 시작하시다가 모르는 부분이.....`, categories: [ - { optionId: 1, content: '🗣️ 커뮤니케이션, 협업 능력' }, - { optionId: 5, content: '🌱 성장 마인드셋' }, + { optionId: 1, content: '🗣️커뮤니케이션, 협업 능력 (예: 팀원간의 원활한 정보 공유, 명확한 의사소통)' }, + { + optionId: 5, + content: + '🌱성장 마인드셋 (예: 새로운 분야나 잘 모르는 분야에 도전하는 마음, 꾸준한 노력으로 프로젝트 이전보다 성장하는 모습)', + }, ], }, { @@ -167,8 +195,8 @@ export const REVIEW_LIST: ReviewList = { createdAt: '2023-08-29', contentPreview: `14. 하루스터디는 효율적인 공부 방법을 제공하는 학습 진행 도구 서비스입니다. 하루스터디는 목표 설정 단계, 학습 단계, 회고 단계를 반복하는 학습 사이클을 통해 학습 효율을 끌어올립니다. 하루스터디를 사용하게 되면 '학습을 잘 하는 방법'에 대해서...`, categories: [ - { optionId: 3, content: '⏰ 시간 관리 능력' }, - { optionId: 4, content: '🤓 기술적 역량, 전문 지식' }, + { optionId: 3, content: '⏰시간 관리 능력 (예: 일정과 마감 기한 준수, 업무의 우선 순위 분배)' }, + { optionId: 4, content: '💻기술적 역량, 전문 지식 (예: 요구 사항을 이해하고 이를 구현하는 능력)' }, ], }, { @@ -176,8 +204,12 @@ export const REVIEW_LIST: ReviewList = { createdAt: '2021-08-01', contentPreview: `15. 공간을 한 눈에, 예약은 한 번에! 맞춤형 공간예약 서비스 제작 플랫폼 찜꽁입니다! 공간 제공자(관리자)는 에디터를 통해 공간을 생성할 수 있습니다! 생성한 공간은 링크를 통해 사용자에게 제공할 수 있으며, 사용자는 링크를 통해 간편하게 공간을 확인하고 예약을...`, categories: [ - { optionId: 5, content: '🌱 성장 마인드셋' }, - { optionId: 1, content: '🗣️ 커뮤니케이션, 협업 능력' }, + { + optionId: 5, + content: + '🌱성장 마인드셋 (예: 새로운 분야나 잘 모르는 분야에 도전하는 마음, 꾸준한 노력으로 프로젝트 이전보다 성장하는 모습)', + }, + { optionId: 1, content: '🗣️커뮤니케이션, 협업 능력 (예: 팀원간의 원활한 정보 공유, 명확한 의사소통)' }, ], }, { @@ -185,8 +217,8 @@ export const REVIEW_LIST: ReviewList = { createdAt: '2021-08-01', contentPreview: `16. 공간을 한 눈에, 예약은 한 번에! 맞춤형 공간예약 서비스 제작 플랫폼 찜꽁입니다! 공간 제공자(관리자)는 에디터를 통해 공간을 생성할 수 있습니다! 생성한 공간은 링크를 통해 사용자에게 제공할 수 있으며, 사용자는 링크를 통해 간편하게 공간을 확인하고 예약을...`, categories: [ - { optionId: 1, content: '🗣️ 커뮤니케이션, 협업 능력' }, - { optionId: 2, content: '💡 문제 해결 능력' }, + { optionId: 1, content: '🗣️커뮤니케이션, 협업 능력 (예: 팀원간의 원활한 정보 공유, 명확한 의사소통)' }, + { optionId: 2, content: '💡문제 해결 능력 (예: 프로젝트 중 만난 버그/오류를 분석하고 이를 해결하는 능력)' }, ], }, { @@ -194,8 +226,8 @@ export const REVIEW_LIST: ReviewList = { createdAt: '2021-08-01', contentPreview: `17. 공간을 한 눈에, 예약은 한 번에! 맞춤형 공간예약 서비스 제작 플랫폼 찜꽁입니다! 공간 제공자(관리자)는 에디터를 통해 공간을 생성할 수 있습니다! 생성한 공간은 링크를 통해 사용자에게 제공할 수 있으며, 사용자는 링크를 통해 간편하게 공간을 확인하고 예약을...`, categories: [ - { optionId: 1, content: '🗣️ 커뮤니케이션, 협업 능력' }, - { optionId: 2, content: '💡 문제 해결 능력' }, + { optionId: 1, content: '🗣️커뮤니케이션, 협업 능력 (예: 팀원간의 원활한 정보 공유, 명확한 의사소통)' }, + { optionId: 2, content: '💡문제 해결 능력 (예: 프로젝트 중 만난 버그/오류를 분석하고 이를 해결하는 능력)' }, ], }, { @@ -203,8 +235,12 @@ export const REVIEW_LIST: ReviewList = { createdAt: '2021-08-01', contentPreview: `18. 공간을 한 눈에, 예약은 한 번에! 맞춤형 공간예약 서비스 제작 플랫폼 찜꽁입니다! 공간 제공자(관리자)는 에디터를 통해 공간을 생성할 수 있습니다! 생성한 공간은 링크를 통해 사용자에게 제공할 수 있으며, 사용자는 링크를 통해 간편하게 공간을 확인하고 예약을...`, categories: [ - { optionId: 5, content: '🌱 성장 마인드셋' }, - { optionId: 1, content: '🗣️ 커뮤니케이션, 협업 능력' }, + { + optionId: 5, + content: + '🌱성장 마인드셋 (예: 새로운 분야나 잘 모르는 분야에 도전하는 마음, 꾸준한 노력으로 프로젝트 이전보다 성장하는 모습)', + }, + { optionId: 1, content: '🗣️커뮤니케이션, 협업 능력 (예: 팀원간의 원활한 정보 공유, 명확한 의사소통)' }, ], }, { @@ -212,8 +248,8 @@ export const REVIEW_LIST: ReviewList = { createdAt: '2021-08-01', contentPreview: `19. 공간을 한 눈에, 예약은 한 번에! 맞춤형 공간예약 서비스 제작 플랫폼 찜꽁입니다! 공간 제공자(관리자)는 에디터를 통해 공간을 생성할 수 있습니다! 생성한 공간은 링크를 통해 사용자에게 제공할 수 있으며, 사용자는 링크를 통해 간편하게 공간을 확인하고 예약을...`, categories: [ - { optionId: 3, content: '⏰ 시간 관리 능력' }, - { optionId: 2, content: '💡 문제 해결 능력' }, + { optionId: 3, content: '⏰시간 관리 능력 (예: 일정과 마감 기한 준수, 업무의 우선 순위 분배)' }, + { optionId: 2, content: '💡문제 해결 능력 (예: 프로젝트 중 만난 버그/오류를 분석하고 이를 해결하는 능력)' }, ], }, { @@ -221,8 +257,12 @@ export const REVIEW_LIST: ReviewList = { createdAt: '2021-08-01', contentPreview: `20. 공간을 한 눈에, 예약은 한 번에! 맞춤형 공간예약 서비스 제작 플랫폼 찜꽁입니다! 공간 제공자(관리자)는 에디터를 통해 공간을 생성할 수 있습니다! 생성한 공간은 링크를 통해 사용자에게 제공할 수 있으며, 사용자는 링크를 통해 간편하게 공간을 확인하고 예약을...`, categories: [ - { optionId: 4, content: '🤓 기술적 역량, 전문 지식' }, - { optionId: 5, content: '🌱 성장 마인드셋' }, + { optionId: 4, content: '💻기술적 역량, 전문 지식 (예: 요구 사항을 이해하고 이를 구현하는 능력)' }, + { + optionId: 5, + content: + '🌱성장 마인드셋 (예: 새로운 분야나 잘 모르는 분야에 도전하는 마음, 꾸준한 노력으로 프로젝트 이전보다 성장하는 모습)', + }, ], }, { @@ -230,8 +270,12 @@ export const REVIEW_LIST: ReviewList = { createdAt: '2021-08-01', contentPreview: `21. 공간을 한 눈에, 예약은 한 번에! 맞춤형 공간예약 서비스 제작 플랫폼 찜꽁입니다! 공간 제공자(관리자)는 에디터를 통해 공간을 생성할 수 있습니다! 생성한 공간은 링크를 통해 사용자에게 제공할 수 있으며, 사용자는 링크를 통해 간편하게 공간을 확인하고 예약을...`, categories: [ - { optionId: 5, content: '🌱 성장 마인드셋' }, - { optionId: 2, content: '💡 문제 해결 능력' }, + { + optionId: 5, + content: + '🌱성장 마인드셋 (예: 새로운 분야나 잘 모르는 분야에 도전하는 마음, 꾸준한 노력으로 프로젝트 이전보다 성장하는 모습)', + }, + { optionId: 2, content: '💡문제 해결 능력 (예: 프로젝트 중 만난 버그/오류를 분석하고 이를 해결하는 능력)' }, ], }, { @@ -239,8 +283,8 @@ export const REVIEW_LIST: ReviewList = { createdAt: '2021-08-01', contentPreview: `22. 공간을 한 눈에, 예약은 한 번에! 맞춤형 공간예약 서비스 제작 플랫폼 찜꽁입니다! 공간 제공자(관리자)는 에디터를 통해 공간을 생성할 수 있습니다! 생성한 공간은 링크를 통해 사용자에게 제공할 수 있으며, 사용자는 링크를 통해 간편하게 공간을 확인하고 예약을...`, categories: [ - { optionId: 1, content: '🗣️ 커뮤니케이션, 협업 능력' }, - { optionId: 3, content: '⏰ 시간 관리 능력' }, + { optionId: 1, content: '🗣️커뮤니케이션, 협업 능력 (예: 팀원간의 원활한 정보 공유, 명확한 의사소통)' }, + { optionId: 3, content: '⏰시간 관리 능력 (예: 일정과 마감 기한 준수, 업무의 우선 순위 분배)' }, ], }, { @@ -248,8 +292,8 @@ export const REVIEW_LIST: ReviewList = { createdAt: '2021-08-01', contentPreview: `23. 공간을 한 눈에, 예약은 한 번에! 맞춤형 공간예약 서비스 제작 플랫폼 찜꽁입니다! 공간 제공자(관리자)는 에디터를 통해 공간을 생성할 수 있습니다! 생성한 공간은 링크를 통해 사용자에게 제공할 수 있으며, 사용자는 링크를 통해 간편하게 공간을 확인하고 예약을...`, categories: [ - { optionId: 1, content: '🗣️ 커뮤니케이션, 협업 능력' }, - { optionId: 2, content: '💡 문제 해결 능력' }, + { optionId: 1, content: '🗣️커뮤니케이션, 협업 능력 (예: 팀원간의 원활한 정보 공유, 명확한 의사소통)' }, + { optionId: 2, content: '💡문제 해결 능력 (예: 프로젝트 중 만난 버그/오류를 분석하고 이를 해결하는 능력)' }, ], }, { @@ -257,8 +301,12 @@ export const REVIEW_LIST: ReviewList = { createdAt: '2021-08-01', contentPreview: `24. 공간을 한 눈에, 예약은 한 번에! 맞춤형 공간예약 서비스 제작 플랫폼 찜꽁입니다! 공간 제공자(관리자)는 에디터를 통해 공간을 생성할 수 있습니다! 생성한 공간은 링크를 통해 사용자에게 제공할 수 있으며, 사용자는 링크를 통해 간편하게 공간을 확인하고 예약을...`, categories: [ - { optionId: 4, content: '🌱 성장 마인드셋' }, - { optionId: 1, content: '🗣️ 커뮤니케이션, 협업 능력' }, + { + optionId: 4, + content: + '🌱성장 마인드셋 (예: 새로운 분야나 잘 모르는 분야에 도전하는 마음, 꾸준한 노력으로 프로젝트 이전보다 성장하는 모습)', + }, + { optionId: 1, content: '🗣️커뮤니케이션, 협업 능력 (예: 팀원간의 원활한 정보 공유, 명확한 의사소통)' }, ], }, ], diff --git a/frontend/src/pages/DetailedReviewPage/components/DetailedReviewPageContents/index.tsx b/frontend/src/pages/DetailedReviewPage/components/DetailedReviewPageContents/index.tsx index cef4b9e72..01b01c9e7 100644 --- a/frontend/src/pages/DetailedReviewPage/components/DetailedReviewPageContents/index.tsx +++ b/frontend/src/pages/DetailedReviewPage/components/DetailedReviewPageContents/index.tsx @@ -2,7 +2,7 @@ import { useMemo } from 'react'; import { ROUTE_PARAM } from '@/constants'; import { useGetDetailedReview, useSearchParamAndQuery } from '@/hooks'; -import { ReviewDescription, ReviewSection, KeywordSection } from '@/pages/DetailedReviewPage/components'; +import { ReviewDescription, QuestionAnswerSection } from '@/pages/DetailedReviewPage/components'; import { substituteString } from '@/utils'; import * as S from './styles'; @@ -46,7 +46,6 @@ const DetailedReviewPageContents = () => { }; }, [detailedReview]); - // TODO: 리뷰 공개/비공개 토글 버튼 기능 return ( { isPublic={true} handleClickToggleButton={() => console.log('click toggle ')} /> - {parsedDetailedReview.sections.map((section) => - section.questions.map((question) => ( - - - {question.questionType === 'CHECKBOX' && } - - )), - )} + + + {parsedDetailedReview.sections.map((section) => + section.questions.map((question) => ( + + + + )), + )} + ); }; diff --git a/frontend/src/pages/DetailedReviewPage/components/DetailedReviewPageContents/styles.ts b/frontend/src/pages/DetailedReviewPage/components/DetailedReviewPageContents/styles.ts index 6acc796b6..74eb3bac8 100644 --- a/frontend/src/pages/DetailedReviewPage/components/DetailedReviewPageContents/styles.ts +++ b/frontend/src/pages/DetailedReviewPage/components/DetailedReviewPageContents/styles.ts @@ -5,7 +5,9 @@ import media from '@/utils/media'; export const DetailedReviewPageContents = styled.div` width: 70%; margin-top: 2rem; - border: 0.1rem solid ${({ theme }) => theme.colors.lightPurple}; + padding: 2rem 3rem; + + border: 0.2rem solid ${({ theme }) => theme.colors.disabled}; border-radius: ${({ theme }) => theme.borderRadius.basic}; ${media.medium} { @@ -17,10 +19,20 @@ export const DetailedReviewPageContents = styled.div` } `; -export const ReviewContentContainer = styled.div` - margin-bottom: 7rem; - padding: 0 4rem; +export const Separator = styled.div` + width: 100%; + height: 0.3rem; + margin: 3rem 0; + background-color: ${({ theme }) => theme.colors.disabled}; +`; +export const DetailedReviewContainer = styled.div` + display: flex; + flex-direction: column; + gap: 4rem; +`; + +export const ReviewContentContainer = styled.div` ${media.xSmall} { padding: 0 2rem; } diff --git a/frontend/src/pages/DetailedReviewPage/components/KeywordSection/index.tsx b/frontend/src/pages/DetailedReviewPage/components/KeywordSection/index.tsx deleted file mode 100644 index 7c9572616..000000000 --- a/frontend/src/pages/DetailedReviewPage/components/KeywordSection/index.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { Options } from '@/types'; - -import * as S from './styles'; - -interface KeywordSectionProps { - options: Options[]; -} - -const KeywordSection = ({ options }: KeywordSectionProps) => { - return ( - - - {options.map(({ optionId, content }) => ( - {content} - ))} - - - ); -}; - -export default KeywordSection; diff --git a/frontend/src/pages/DetailedReviewPage/components/KeywordSection/styles.ts b/frontend/src/pages/DetailedReviewPage/components/KeywordSection/styles.ts deleted file mode 100644 index 09f7c695e..000000000 --- a/frontend/src/pages/DetailedReviewPage/components/KeywordSection/styles.ts +++ /dev/null @@ -1,15 +0,0 @@ -import styled from '@emotion/styled'; - -export const KeywordSection = styled.section` - width: 100%; - margin-top: 2rem; -`; - -export const KeywordList = styled.ul` - padding-left: 2rem; - list-style-type: disc; -`; - -export const KeywordItem = styled.li` - margin-bottom: 0.5rem; -`; diff --git a/frontend/src/pages/DetailedReviewPage/components/MultipleChoiceAnswer/index.tsx b/frontend/src/pages/DetailedReviewPage/components/MultipleChoiceAnswer/index.tsx new file mode 100644 index 000000000..2c0dbd4f7 --- /dev/null +++ b/frontend/src/pages/DetailedReviewPage/components/MultipleChoiceAnswer/index.tsx @@ -0,0 +1,11 @@ +import formatKeyword from '@/utils/formatKeyword'; + +const MultipleChoiceAnswer = ({ selectedOption }: { selectedOption: string }) => { + const isExampleIncluded = selectedOption.includes('예'); + + const formattedAnswer = formatKeyword(selectedOption); + + return
  • {isExampleIncluded ? formattedAnswer : selectedOption}
  • ; +}; + +export default MultipleChoiceAnswer; diff --git a/frontend/src/pages/DetailedReviewPage/components/MultipleChoiceAnswerList/index.tsx b/frontend/src/pages/DetailedReviewPage/components/MultipleChoiceAnswerList/index.tsx new file mode 100644 index 000000000..da9a4d5d1 --- /dev/null +++ b/frontend/src/pages/DetailedReviewPage/components/MultipleChoiceAnswerList/index.tsx @@ -0,0 +1,21 @@ +import { Options } from '@/types'; + +import MultipleChoiceAnswer from '../MultipleChoiceAnswer'; + +import * as S from './styles'; + +interface MultipleChoiceAnswerListProps { + selectedOptionList: Options[]; +} + +const MultipleChoiceAnswerList = ({ selectedOptionList }: MultipleChoiceAnswerListProps) => { + return ( + + {selectedOptionList.map(({ optionId, content }) => ( + + ))} + + ); +}; + +export default MultipleChoiceAnswerList; diff --git a/frontend/src/pages/DetailedReviewPage/components/MultipleChoiceAnswerList/styles.ts b/frontend/src/pages/DetailedReviewPage/components/MultipleChoiceAnswerList/styles.ts new file mode 100644 index 000000000..db52191cf --- /dev/null +++ b/frontend/src/pages/DetailedReviewPage/components/MultipleChoiceAnswerList/styles.ts @@ -0,0 +1,11 @@ +import styled from '@emotion/styled'; + +export const MultipleChoiceAnswerList = styled.ul` + display: flex; + flex-direction: column; + gap: 0.5rem; + + padding-left: 4rem; + + list-style-type: disc; +`; diff --git a/frontend/src/pages/DetailedReviewPage/components/QuestionAnswerSection/index.tsx b/frontend/src/pages/DetailedReviewPage/components/QuestionAnswerSection/index.tsx new file mode 100644 index 000000000..a1606b0a1 --- /dev/null +++ b/frontend/src/pages/DetailedReviewPage/components/QuestionAnswerSection/index.tsx @@ -0,0 +1,28 @@ +import { MultilineTextViewer } from '@/components'; +import { MultipleChoiceAnswerList, QuestionTitle } from '@/pages/DetailedReviewPage/components'; +import { Options, QuestionType } from '@/types'; + +import * as S from './styles'; + +interface QuestionAnswerProps { + question: string; + questionType: QuestionType; + answer?: string; + options?: Options[]; +} + +const QuestionAnswerSection = ({ question, questionType, answer, options }: QuestionAnswerProps) => { + return ( + + + {questionType === 'CHECKBOX' && options && } + {questionType === 'TEXT' && answer && ( + + + + )} + + ); +}; + +export default QuestionAnswerSection; diff --git a/frontend/src/pages/DetailedReviewPage/components/ReviewSection/styles.ts b/frontend/src/pages/DetailedReviewPage/components/QuestionAnswerSection/styles.ts similarity index 59% rename from frontend/src/pages/DetailedReviewPage/components/ReviewSection/styles.ts rename to frontend/src/pages/DetailedReviewPage/components/QuestionAnswerSection/styles.ts index eb8917aa8..59e13d1b2 100644 --- a/frontend/src/pages/DetailedReviewPage/components/ReviewSection/styles.ts +++ b/frontend/src/pages/DetailedReviewPage/components/QuestionAnswerSection/styles.ts @@ -1,16 +1,18 @@ import styled from '@emotion/styled'; -export const ReviewSection = styled.section` +export const QuestionAnswerSection = styled.section` + display: flex; + flex-direction: column; + gap: 2rem; width: 100%; - margin-top: 3.2rem; `; -export const Answer = styled.div` +export const TextAnswerWrapper = styled.div` overflow-y: auto; box-sizing: border-box; width: 100%; - height: 23rem; + height: 20rem; padding: 1rem 1.5rem; font-size: 1.6rem; diff --git a/frontend/src/pages/DetailedReviewPage/components/QuestionTitle/index.tsx b/frontend/src/pages/DetailedReviewPage/components/QuestionTitle/index.tsx new file mode 100644 index 000000000..65fdd38d1 --- /dev/null +++ b/frontend/src/pages/DetailedReviewPage/components/QuestionTitle/index.tsx @@ -0,0 +1,11 @@ +import * as S from './styles'; + +interface QuestionProps { + text: string; +} + +const QuestionTitle = ({ text }: QuestionProps) => { + return {text}; +}; + +export default QuestionTitle; diff --git a/frontend/src/pages/DetailedReviewPage/components/QuestionTitle/styles.ts b/frontend/src/pages/DetailedReviewPage/components/QuestionTitle/styles.ts new file mode 100644 index 000000000..f29376283 --- /dev/null +++ b/frontend/src/pages/DetailedReviewPage/components/QuestionTitle/styles.ts @@ -0,0 +1,5 @@ +import styled from '@emotion/styled'; + +export const QuestionTitle = styled.p` + font-weight: ${({ theme }) => theme.fontWeight.bold}; +`; diff --git a/frontend/src/pages/DetailedReviewPage/components/ReviewDescription/index.tsx b/frontend/src/pages/DetailedReviewPage/components/ReviewDescription/index.tsx index ed4e721b8..72dc2f745 100644 --- a/frontend/src/pages/DetailedReviewPage/components/ReviewDescription/index.tsx +++ b/frontend/src/pages/DetailedReviewPage/components/ReviewDescription/index.tsx @@ -2,7 +2,7 @@ import ReviewDate, { ReviewDateProps } from '@/components/common/ReviewDate'; import * as S from './styles'; -const DATE_TITLE = '리뷰 작성일'; +const DATE_TITLE = '작성일'; interface ReviewDescriptionProps extends Omit { projectName: string; diff --git a/frontend/src/pages/DetailedReviewPage/components/ReviewDescription/styles.ts b/frontend/src/pages/DetailedReviewPage/components/ReviewDescription/styles.ts index e6cfa2323..52ee76dff 100644 --- a/frontend/src/pages/DetailedReviewPage/components/ReviewDescription/styles.ts +++ b/frontend/src/pages/DetailedReviewPage/components/ReviewDescription/styles.ts @@ -9,9 +9,7 @@ export const Description = styled.section` width: 100%; margin: 0; - padding: 1rem 3rem; - background-color: ${({ theme }) => theme.colors.lightPurple}; border-radius: ${({ theme }) => theme.borderRadius.basic} ${({ theme }) => theme.borderRadius.basic} 0 0; ${media.xSmall} { @@ -29,9 +27,7 @@ export const ProjectInfoContainer = styled.div` display: flex; flex-direction: column; justify-content: flex-start; - width: 100%; - margin: 0 1rem; `; export const ProjectName = styled.p` diff --git a/frontend/src/pages/DetailedReviewPage/components/ReviewSection/index.tsx b/frontend/src/pages/DetailedReviewPage/components/ReviewSection/index.tsx deleted file mode 100644 index c059715b6..000000000 --- a/frontend/src/pages/DetailedReviewPage/components/ReviewSection/index.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { MultilineTextViewer } from '@/components'; - -import ReviewSectionHeader from '../ReviewSectionHeader'; - -import * as S from './styles'; - -interface ReviewSectionProps { - question: string; - answer: string; -} - -const ReviewSection = ({ question, answer }: ReviewSectionProps) => { - return ( - - - {answer && ( - - - - )} - - ); -}; - -export default ReviewSection; diff --git a/frontend/src/pages/DetailedReviewPage/components/ReviewSectionHeader/index.tsx b/frontend/src/pages/DetailedReviewPage/components/ReviewSectionHeader/index.tsx deleted file mode 100644 index 50fa0dab5..000000000 --- a/frontend/src/pages/DetailedReviewPage/components/ReviewSectionHeader/index.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import * as S from './styles'; - -interface ReviewSectionHeaderProps { - text: string; -} - -const ReviewSectionHeader = ({ text }: ReviewSectionHeaderProps) => { - return {text}; -}; - -export default ReviewSectionHeader; diff --git a/frontend/src/pages/DetailedReviewPage/components/ReviewSectionHeader/styles.ts b/frontend/src/pages/DetailedReviewPage/components/ReviewSectionHeader/styles.ts deleted file mode 100644 index 517c62e27..000000000 --- a/frontend/src/pages/DetailedReviewPage/components/ReviewSectionHeader/styles.ts +++ /dev/null @@ -1,7 +0,0 @@ -import styled from '@emotion/styled'; - -export const ReviewSectionHeader = styled.p` - margin-bottom: 1rem; - font-size: 1.6rem; - font-weight: bold; -`; diff --git a/frontend/src/pages/DetailedReviewPage/components/index.tsx b/frontend/src/pages/DetailedReviewPage/components/index.tsx index 4bfb172bd..a455c2400 100644 --- a/frontend/src/pages/DetailedReviewPage/components/index.tsx +++ b/frontend/src/pages/DetailedReviewPage/components/index.tsx @@ -1,4 +1,5 @@ -export { default as KeywordSection } from './KeywordSection'; +export { default as QuestionAnswerSection } from './QuestionAnswerSection'; +export { default as QuestionTitle } from './QuestionTitle'; +export { default as MultipleChoiceAnswerList } from './MultipleChoiceAnswerList'; export { default as ReviewDescription } from './ReviewDescription'; -export { default as ReviewSection } from './ReviewSection'; export { default as DetailedReviewPageContents } from './DetailedReviewPageContents'; diff --git a/frontend/src/pages/DetailedReviewPage/index.tsx b/frontend/src/pages/DetailedReviewPage/index.tsx index fd316a205..8b4701c92 100644 --- a/frontend/src/pages/DetailedReviewPage/index.tsx +++ b/frontend/src/pages/DetailedReviewPage/index.tsx @@ -4,7 +4,7 @@ import { DetailedReviewPageContents } from './components'; const DetailedReviewPage = () => { return ( - + diff --git a/frontend/src/pages/HomePage/components/FormBody/index.tsx b/frontend/src/pages/HomePage/components/FormBody/index.tsx deleted file mode 100644 index bb43455b1..000000000 --- a/frontend/src/pages/HomePage/components/FormBody/index.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import React from 'react'; - -import { EssentialPropsWithChildren } from '@/types'; - -import * as S from './styles'; - -interface FormBodyProps { - direction: React.CSSProperties['flexDirection']; -} - -const FormBody: React.FC> = ({ direction, children }) => { - return {children}; -}; - -export default FormBody; diff --git a/frontend/src/pages/HomePage/components/FormBody/styles.ts b/frontend/src/pages/HomePage/components/FormBody/styles.ts deleted file mode 100644 index 714ec27a5..000000000 --- a/frontend/src/pages/HomePage/components/FormBody/styles.ts +++ /dev/null @@ -1,7 +0,0 @@ -import styled from '@emotion/styled'; - -export const FormBody = styled.div<{ direction: React.CSSProperties['flexDirection'] }>` - display: flex; - flex-direction: ${({ direction }) => direction}; - gap: 1.8rem; -`; diff --git a/frontend/src/pages/HomePage/components/FormLayout/index.tsx b/frontend/src/pages/HomePage/components/FormLayout/index.tsx deleted file mode 100644 index 2bcd60e75..000000000 --- a/frontend/src/pages/HomePage/components/FormLayout/index.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import React from 'react'; - -import { EssentialPropsWithChildren } from '@/types'; - -import { FormBody } from '../index'; - -import * as S from './styles'; - -interface FormProps { - title: string; - direction: React.CSSProperties['flexDirection']; -} - -const FormLayout: React.FC> = ({ title, direction, children }) => { - return ( - - {title} - {children} - - ); -}; - -export default FormLayout; diff --git a/frontend/src/pages/HomePage/components/FormLayout/styles.ts b/frontend/src/pages/HomePage/components/FormLayout/styles.ts deleted file mode 100644 index d1cbc1f17..000000000 --- a/frontend/src/pages/HomePage/components/FormLayout/styles.ts +++ /dev/null @@ -1,13 +0,0 @@ -import styled from '@emotion/styled'; - -export const FormLayout = styled.form` - display: flex; - flex-direction: column; - justify-content: center; -`; - -export const Title = styled.h2` - margin-bottom: 6.5rem; - font-size: 2.5rem; - white-space: nowrap; -`; diff --git a/frontend/src/pages/HomePage/components/InfinityCarousel/index.tsx b/frontend/src/pages/HomePage/components/InfinityCarousel/index.tsx index d5a9cc5fe..ce6153278 100644 --- a/frontend/src/pages/HomePage/components/InfinityCarousel/index.tsx +++ b/frontend/src/pages/HomePage/components/InfinityCarousel/index.tsx @@ -2,8 +2,11 @@ import { useRef, useState, useEffect } from 'react'; import nextArrowIcon from '@/assets/nextArrow.svg'; import prevArrowIcon from '@/assets/prevArrow.svg'; +import { ImgWithSkeleton } from '@/components'; import { breakpoints } from '@/styles/theme'; +import useSlideImgSize from '../../hooks/useSlideImgSize'; + import * as S from './styles'; export interface Slide { @@ -37,6 +40,7 @@ const InfinityCarousel = ({ slideList }: InfinityCarouselProps) => { const [deltaX, setDeltaX] = useState(0); // 현재 드래그 중인 위치와 시작 위치 사이의 차이 const slideRef = useRef(null); + const { imgSize } = useSlideImgSize({ slideRef }); const slideLength = slideList.length; // 첫 슬라이드와 마지막 슬라이드의 복제본을 각각 맨 뒤, 맨 처음에 추가 @@ -151,7 +155,9 @@ const InfinityCarousel = ({ slideList }: InfinityCarouselProps) => { {clonedSlideList.map((slide, index) => ( - {slide.alt} + + + ))} diff --git a/frontend/src/pages/HomePage/components/InfinityCarousel/styles.ts b/frontend/src/pages/HomePage/components/InfinityCarousel/styles.ts index 9d17162cc..90e59eac6 100644 --- a/frontend/src/pages/HomePage/components/InfinityCarousel/styles.ts +++ b/frontend/src/pages/HomePage/components/InfinityCarousel/styles.ts @@ -46,10 +46,10 @@ export const SlideContent = styled.div` justify-content: space-between; width: 100%; +`; - img { - width: 80%; - } +export const SlideContentImg = styled.img` + width: 100%; `; export const PrevButton = styled.button` diff --git a/frontend/src/pages/HomePage/components/Inputs/ProjectNameField.tsx b/frontend/src/pages/HomePage/components/Inputs/ProjectNameField.tsx deleted file mode 100644 index 433683907..000000000 --- a/frontend/src/pages/HomePage/components/Inputs/ProjectNameField.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { useEffect, useState } from 'react'; - -import { Input } from '@/components'; -import { isWithinLengthRange, MAX_VALID_REVIEW_GROUP_DATA_INPUT } from '@/pages/HomePage/utils/validateInput'; - -import { InputValueProps } from './InputField'; - -import { InputField } from './'; - -const ProjectNameField = ({ id, value: revieweeName, setValue: setRevieweeName }: InputValueProps) => { - const [errorMessage, setErrorMessage] = useState(''); - - useEffect(() => { - isWithinLengthRange(revieweeName, MAX_VALID_REVIEW_GROUP_DATA_INPUT) - ? setErrorMessage('') - : setErrorMessage(`최대 ${MAX_VALID_REVIEW_GROUP_DATA_INPUT}자까지 입력할 수 있어요`); - }, [revieweeName]); - - return ( - - { - setRevieweeName(event.target.value); - }} - /> - - ); -}; - -export default ProjectNameField; diff --git a/frontend/src/pages/HomePage/components/Inputs/RevieweeNameField.tsx b/frontend/src/pages/HomePage/components/Inputs/RevieweeNameField.tsx deleted file mode 100644 index 8b128c575..000000000 --- a/frontend/src/pages/HomePage/components/Inputs/RevieweeNameField.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { useEffect, useState } from 'react'; - -import { Input } from '@/components'; -import { isWithinLengthRange, MAX_VALID_REVIEW_GROUP_DATA_INPUT } from '@/pages/HomePage/utils/validateInput'; - -import { InputValueProps } from './InputField'; - -import { InputField } from '.'; - -const RevieweeNameField = ({ id, value: projectName, setValue: setProjectName }: InputValueProps) => { - const [errorMessage, setErrorMessage] = useState(''); - - useEffect(() => { - isWithinLengthRange(projectName, MAX_VALID_REVIEW_GROUP_DATA_INPUT) - ? setErrorMessage('') - : setErrorMessage(`최대 ${MAX_VALID_REVIEW_GROUP_DATA_INPUT}자까지 입력할 수 있어요`); - }, [projectName]); - - return ( - - { - setProjectName(event.target.value); - }} - /> - - ); -}; - -export default RevieweeNameField; diff --git a/frontend/src/pages/HomePage/components/Inputs/index.ts b/frontend/src/pages/HomePage/components/Inputs/index.ts deleted file mode 100644 index 93305867d..000000000 --- a/frontend/src/pages/HomePage/components/Inputs/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { default as ProjectNameField } from './ProjectNameField'; -export { default as RevieweeNameField } from './RevieweeNameField'; -export { default as PasswordField } from './PasswordField'; -export { default as InputField } from './InputField'; diff --git a/frontend/src/pages/HomePage/components/URLGeneratorForm/index.tsx b/frontend/src/pages/HomePage/components/URLGeneratorForm/index.tsx deleted file mode 100644 index 63a18c6c6..000000000 --- a/frontend/src/pages/HomePage/components/URLGeneratorForm/index.tsx +++ /dev/null @@ -1,98 +0,0 @@ -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, trackEventInAmplitude } from '@/utils'; - -import usePostDataForReviewRequestCode from '../../hooks/usePostDataForReviewRequestCode'; -import { FormLayout, ReviewZoneURLModal } from '../index'; -import { ProjectNameField, RevieweeNameField, PasswordField } from '../Inputs'; - -import * as S from './styles'; - -const DEBOUNCE_TIME = 300; - -const MODAL_KEYS = { - confirm: 'CONFIRM', -}; - -const URLGeneratorForm = () => { - const [revieweeName, setRevieweeName] = useState(''); - const [projectName, setProjectName] = useState(''); - const [password, setPassword] = useState(''); - - const [reviewZoneURL, setReviewZoneURL] = useState(''); - - const { isOpen, openModal, closeModal } = useModals(); - - const useInputId = useId(); - const INPUT_ID = { - revieweeName: `reviewee-name-input-${useInputId}`, - projectName: `project-name-input-${useInputId}`, - password: `password-input-${useInputId}`, - }; - - const mutation = usePostDataForReviewRequestCode(); - - const isFormValid = - isValidReviewGroupDataInput(revieweeName) && - isValidReviewGroupDataInput(projectName) && - isValidPasswordInput(password); - - const postDataForURL = () => { - trackEventInAmplitude(HOM_EVENT_NAME.generateReviewURL); - - const dataForReviewRequestCode: DataForReviewRequestCode = { revieweeName, projectName, groupAccessCode: password }; - mutation.mutate(dataForReviewRequestCode, { - onSuccess: (data) => { - const completeReviewZoneURL = getCompleteReviewZoneURL(data.reviewRequestCode); - setReviewZoneURL(completeReviewZoneURL); - - resetForm(); - }, - }); - }; - - const resetForm = () => { - setRevieweeName(''); - setProjectName(''); - setPassword(''); - }; - - const getCompleteReviewZoneURL = (reviewRequestCode: string) => { - return `${window.location.origin}/${ROUTE.reviewZone}/${reviewRequestCode}`; - }; - - const handleUrlCreationButtonClick = debounce((event: React.MouseEvent) => { - event.preventDefault(); - postDataForURL(); - openModal(MODAL_KEYS.confirm); - }, DEBOUNCE_TIME); - - return ( - - - - - - - {isOpen(MODAL_KEYS.confirm) && ( - closeModal(MODAL_KEYS.confirm)} /> - )} - - - ); -}; - -export default URLGeneratorForm; diff --git a/frontend/src/pages/HomePage/components/URLGeneratorForm/styles.ts b/frontend/src/pages/HomePage/components/URLGeneratorForm/styles.ts deleted file mode 100644 index 80fcda2fa..000000000 --- a/frontend/src/pages/HomePage/components/URLGeneratorForm/styles.ts +++ /dev/null @@ -1,90 +0,0 @@ -import styled from '@emotion/styled'; - -import media from '@/utils/media'; - -export const URLGeneratorForm = styled.section` - display: flex; - align-items: center; - justify-content: center; - - width: 40%; - padding: 0 9rem; - - ${media.medium} { - width: 45%; - - h2 { - font-size: 2rem; - } - } - - ${media.small} { - width: 100%; - margin: 5rem 0 4rem 0; - } - - ${media.xSmall} { - h2 { - margin-bottom: 4rem; - font-size: 1.8rem; - } - - label { - font-size: 1.5rem; - } - - p { - font-size: 1.3rem; - } - - button { - font-size: 1.5rem; - } - } - - ${media.xxSmall} { - h2 { - font-size: 1.6rem; - } - - label { - font-size: 1.3rem; - } - - p { - font-size: 1.1rem; - } - - button { - font-size: 1.3rem; - } - } -`; - -export const InputContainer = styled.div` - position: relative; - display: flex; - flex-direction: column; - gap: 0.2rem; -`; - -export const PasswordInputContainer = styled.div` - position: relative; - display: flex; -`; - -export const Label = styled.label` - margin-bottom: 1.2rem; -`; - -export const InputInfo = styled.p` - margin: 0.5rem 0.3rem 0.4rem; - font-size: 1.2rem; -`; - -export const ErrorMessage = styled.p` - height: 1.3rem; - padding-left: 0.7rem; - font-size: 1.3rem; - color: ${({ theme }) => theme.colors.red}; -`; diff --git a/frontend/src/pages/HomePage/components/index.ts b/frontend/src/pages/HomePage/components/index.ts deleted file mode 100644 index a6b48c293..000000000 --- a/frontend/src/pages/HomePage/components/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { default as FormBody } from './FormBody'; -export { default as FormLayout } from './FormLayout'; -export { default as ReviewZoneURLModal } from './ReviewZoneURLModal'; -export { default as URLGeneratorForm } from './URLGeneratorForm'; -export { default as CopyTextButton } from './CopyTextButton'; -export { default as ReviewMeOverview } from './ReviewMeOverview'; -export { default as InfinityCarousel } from './InfinityCarousel'; diff --git a/frontend/src/pages/HomePage/components/index.tsx b/frontend/src/pages/HomePage/components/index.tsx new file mode 100644 index 000000000..67074719f --- /dev/null +++ b/frontend/src/pages/HomePage/components/index.tsx @@ -0,0 +1,2 @@ +export { default as ReviewMeOverview } from './ReviewMeOverview'; +export { default as InfinityCarousel } from './InfinityCarousel'; diff --git a/frontend/src/pages/HomePage/hooks/useSlideImgSize.tsx b/frontend/src/pages/HomePage/hooks/useSlideImgSize.tsx new file mode 100644 index 000000000..bbf3d74d2 --- /dev/null +++ b/frontend/src/pages/HomePage/hooks/useSlideImgSize.tsx @@ -0,0 +1,42 @@ +import { useEffect, useState } from 'react'; + +import { debounce } from '@/utils'; + +const DEBOUNCE_TIME = 300; + +interface UseSlideImgSizeProps { + slideRef: React.RefObject; +} +const useSlideImgSize = ({ slideRef }: UseSlideImgSizeProps) => { + interface ImgSize { + width: string; + height: string; + } + const [imgSize, setImgSize] = useState({ width: '', height: '' }); + + const updateImgSize = () => { + if (!slideRef.current) return; + + const slideDomRect = slideRef.current.getBoundingClientRect(); + const width = Math.ceil(slideDomRect.width * 0.8 * 0.1); + const height = width * 0.61; + + setImgSize({ width: `${width}rem`, height: `${height}rem` }); + }; + + const debouncedUpdateImgSize = debounce(updateImgSize, DEBOUNCE_TIME); + + useEffect(() => { + updateImgSize(); + + document.addEventListener('resize', debouncedUpdateImgSize); + + return () => { + document.removeEventListener('resize', debouncedUpdateImgSize); + }; + }, [slideRef]); + + return { imgSize }; +}; + +export default useSlideImgSize; diff --git a/frontend/src/pages/HomePage/index.tsx b/frontend/src/pages/HomePage/index.tsx index be8f082ca..3bc88104a 100644 --- a/frontend/src/pages/HomePage/index.tsx +++ b/frontend/src/pages/HomePage/index.tsx @@ -1,6 +1,6 @@ -import { ErrorSuspenseContainer } from '@/components'; +import { ErrorSuspenseContainer, URLGeneratorForm } from '@/components'; -import { ReviewMeOverview, URLGeneratorForm } from './components'; +import { ReviewMeOverview } from './components'; import * as S from './styles'; const HomePage = () => { diff --git a/frontend/src/pages/ReviewCollectionPage/components/ReviewCollectionPageContents/index.tsx b/frontend/src/pages/ReviewCollectionPage/components/ReviewCollectionPageContents/index.tsx index 5b8f77b54..44cd6c5e0 100644 --- a/frontend/src/pages/ReviewCollectionPage/components/ReviewCollectionPageContents/index.tsx +++ b/frontend/src/pages/ReviewCollectionPage/components/ReviewCollectionPageContents/index.tsx @@ -1,10 +1,10 @@ -import React, { useContext, useState } from 'react'; +import { useContext, useEffect, 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 { REVIEW_EMPTY, SESSION_STORAGE_KEY } from '@/constants'; import { GroupedReview } from '@/types'; import { substituteString } from '@/utils'; @@ -18,6 +18,7 @@ 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 }; }); @@ -29,6 +30,12 @@ const ReviewCollectionPageContents = () => { review.votes?.sort((voteA, voteB) => voteB.count - voteA.count); }); + useEffect(() => { + return () => { + sessionStorage.removeItem(SESSION_STORAGE_KEY.currentReviewCollectionSectionId); + }; + }, []); + const renderContent = (review: GroupedReview) => { if (review.question.type === 'CHECKBOX') { const hasNoCheckboxAnswer = review.votes?.every((vote) => vote.count === 0); diff --git a/frontend/src/pages/ReviewCollectionPage/hooks/useGetGroupedReviews.ts b/frontend/src/pages/ReviewCollectionPage/hooks/useGetGroupedReviews.ts index be16a1427..27b2d687f 100644 --- a/frontend/src/pages/ReviewCollectionPage/hooks/useGetGroupedReviews.ts +++ b/frontend/src/pages/ReviewCollectionPage/hooks/useGetGroupedReviews.ts @@ -1,7 +1,7 @@ import { useSuspenseQuery } from '@tanstack/react-query'; import { getGroupedReviews } from '@/apis/review'; -import { REVIEW_QUERY_KEY } from '@/constants'; +import { REVIEW_QUERY_KEY, SESSION_STORAGE_KEY } from '@/constants'; import { GroupedReviews } from '@/types'; interface UseGetGroupedReviewsProps { @@ -11,6 +11,7 @@ interface UseGetGroupedReviewsProps { const useGetGroupedReviews = ({ sectionId }: UseGetGroupedReviewsProps) => { const fetchGroupedReviews = async () => { const result = await getGroupedReviews({ sectionId }); + sessionStorage.setItem(SESSION_STORAGE_KEY.currentReviewCollectionSectionId, sectionId.toString()); return result; }; diff --git a/frontend/src/pages/ReviewCollectionPage/index.tsx b/frontend/src/pages/ReviewCollectionPage/index.tsx index 9b838b0d0..972340e31 100644 --- a/frontend/src/pages/ReviewCollectionPage/index.tsx +++ b/frontend/src/pages/ReviewCollectionPage/index.tsx @@ -1,35 +1,13 @@ -import { useEffect } from 'react'; - -import { AuthAndServerErrorFallback, ErrorSuspenseContainer, TopButton } from '@/components'; +import { AuthAndServerErrorFallback, ErrorSuspenseContainer } from '@/components'; import ReviewDisplayLayout from '@/components/layouts/ReviewDisplayLayout'; -import { SESSION_STORAGE_KEY } from '@/constants'; import ReviewCollectionPageContents from './components/ReviewCollectionPageContents'; const ReviewCollectionPage = () => { - const clearEditorAnswerMapStorage = () => { - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i); - - // 키에 특정 문자열이 포함되어 있는지 확인 - if (key?.includes(SESSION_STORAGE_KEY.editorAnswerMap)) { - localStorage.removeItem(key); // 해당 키 삭제 - i--; // removeItem 후에 인덱스가 변경되므로 i를 감소시켜야 함 - } - } - }; - - useEffect(() => { - return () => { - clearEditorAnswerMapStorage(); - }; - }, []); - return ( - + - ); diff --git a/frontend/src/pages/ReviewLinkPage/components/ReviewLinkDashboard/index.tsx b/frontend/src/pages/ReviewLinkPage/components/ReviewLinkDashboard/index.tsx new file mode 100644 index 000000000..e6e6f3324 --- /dev/null +++ b/frontend/src/pages/ReviewLinkPage/components/ReviewLinkDashboard/index.tsx @@ -0,0 +1,35 @@ +import { URLGeneratorForm, ReviewCard } from '@/components'; + +import ReviewLinkLayout from '../layouts/ReviewLinkLayout'; + +import * as S from './styles'; + +const ReviewLinkDashboard = () => { + return ( + + + + + + + + {/* TODO: 생성한 리뷰 링크가 없을 경우, 돋보기 컴포넌트 추가 */} + {}} + /> + + + + ); +}; + +export default ReviewLinkDashboard; diff --git a/frontend/src/pages/ReviewLinkPage/components/ReviewLinkDashboard/styles.ts b/frontend/src/pages/ReviewLinkPage/components/ReviewLinkDashboard/styles.ts new file mode 100644 index 000000000..ecf34b973 --- /dev/null +++ b/frontend/src/pages/ReviewLinkPage/components/ReviewLinkDashboard/styles.ts @@ -0,0 +1,79 @@ +import styled from '@emotion/styled'; + +import media from '@/utils/media'; + +export const ReviewLinkDashboardContainer = styled.div` + display: flex; + justify-content: center; + gap: 7rem; + + width: 100%; + // 전체 영역에서 헤더(7rem), 푸터(6rem), 네비게이션 탭(4rem) 영역 제외 + min-height: calc(100vh - 17rem); + + ${media.medium} { + gap: 4rem; + } + + @media screen and (max-width: 900px) { + gap: 2rem; + } + + ${media.small} { + flex-direction: column; + justify-content: flex-start; + align-items: center; + } +`; + +export const FormSection = styled.section` + section { + width: auto; + } + + padding: 5rem 0; + + ${media.medium} { + section { + padding: 0; + } + } + + ${media.small} { + width: 100%; + padding: 0; + } +`; + +export const Separator = styled.div` + width: 0.1rem; + + background-color: ${({ theme }) => theme.colors.lightGray}; + + ${media.small} { + display: none; + } +`; + +export const LinkSection = styled.section` + display: flex; + flex-direction: column; + justify-content: flex-start; + gap: 3rem; + + width: 100%; + + padding: 5rem 0; + + ${media.medium} { + width: 50%; + } + + ${media.small} { + width: 85%; + } + + ${media.xSmall} { + width: 90%; + } +`; diff --git a/frontend/src/pages/ReviewLinkPage/components/layouts/ReviewLinkLayout/index.tsx b/frontend/src/pages/ReviewLinkPage/components/layouts/ReviewLinkLayout/index.tsx new file mode 100644 index 000000000..b6ca6fee1 --- /dev/null +++ b/frontend/src/pages/ReviewLinkPage/components/layouts/ReviewLinkLayout/index.tsx @@ -0,0 +1,22 @@ +import { EssentialPropsWithChildren } from '@/types'; + +import * as S from './styles'; + +interface ReviewLinkListLayoutProps { + title: string; + subTitle: string; +} + +const ReviewLinkLayout = ({ title, subTitle, children }: EssentialPropsWithChildren) => { + return ( + + + {title} + {subTitle} + + {children} + + ); +}; + +export default ReviewLinkLayout; diff --git a/frontend/src/pages/ReviewLinkPage/components/layouts/ReviewLinkLayout/styles.ts b/frontend/src/pages/ReviewLinkPage/components/layouts/ReviewLinkLayout/styles.ts new file mode 100644 index 000000000..ed8ec571f --- /dev/null +++ b/frontend/src/pages/ReviewLinkPage/components/layouts/ReviewLinkLayout/styles.ts @@ -0,0 +1,58 @@ +import styled from '@emotion/styled'; + +import media from '@/utils/media'; + +export const ReviewLinkLayout = styled.div` + display: flex; + flex-direction: column; + + gap: 4rem; +`; + +export const TitleWrapper = styled.div` + display: flex; + flex-direction: column; + gap: 0.8rem; +`; + +export const Title = styled.h2` + ${media.medium} { + font-size: 2rem; + } + + ${media.xSmall} { + font-size: 1.8rem; + } + + ${media.xxSmall} { + font-size: 1.6rem; + } +`; + +export const SubTitle = styled.span` + color: ${({ theme }) => theme.colors.gray}; + + ${media.xSmall} { + font-size: 1.5rem; + } + + ${media.xxSmall} { + font-size: 1.3rem; + } +`; + +export const CardList = styled.ul` + display: flex; + flex-direction: column; + gap: 4rem; + + max-height: calc(100vh - 34rem); + overflow-y: auto; + + padding-right: 2rem; + + ${media.small} { + max-height: none; + padding-right: 0; + } +`; diff --git a/frontend/src/pages/ReviewLinkPage/index.tsx b/frontend/src/pages/ReviewLinkPage/index.tsx new file mode 100644 index 000000000..a7b1761df --- /dev/null +++ b/frontend/src/pages/ReviewLinkPage/index.tsx @@ -0,0 +1,15 @@ +import { ErrorSuspenseContainer } from '@/components'; +import NavigationTab from '@/components/common/NavigationTab'; + +import ReviewLinkDashboard from './components/ReviewLinkDashboard'; + +const ReviewLinkPage = () => { + return ( + + + + + ); +}; + +export default ReviewLinkPage; diff --git a/frontend/src/pages/ReviewListPage/components/ReviewListPageContents/index.tsx b/frontend/src/pages/ReviewListPage/components/ReviewListPageContents/index.tsx index 172242ad0..3d8f11597 100644 --- a/frontend/src/pages/ReviewListPage/components/ReviewListPageContents/index.tsx +++ b/frontend/src/pages/ReviewListPage/components/ReviewListPageContents/index.tsx @@ -2,9 +2,9 @@ import { useContext } from 'react'; import { useNavigate } from 'react-router'; import { ReviewEmptySection } from '@/components'; +import ReviewCard from '@/components/common/ReviewCard'; 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'; diff --git a/frontend/src/pages/ReviewListPage/index.tsx b/frontend/src/pages/ReviewListPage/index.tsx index a2aec6cdf..318b6bfcc 100644 --- a/frontend/src/pages/ReviewListPage/index.tsx +++ b/frontend/src/pages/ReviewListPage/index.tsx @@ -1,14 +1,13 @@ -import { ErrorSuspenseContainer, AuthAndServerErrorFallback, TopButton } from '@/components'; +import { ErrorSuspenseContainer, AuthAndServerErrorFallback } from '@/components'; import ReviewDisplayLayout from '@/components/layouts/ReviewDisplayLayout'; import ReviewListPageContents from './components/ReviewListPageContents'; const ReviewListPage = () => { return ( - + - ); diff --git a/frontend/src/pages/ReviewZonePage/index.tsx b/frontend/src/pages/ReviewZonePage/index.tsx index 790431550..aa6523668 100644 --- a/frontend/src/pages/ReviewZonePage/index.tsx +++ b/frontend/src/pages/ReviewZonePage/index.tsx @@ -3,9 +3,8 @@ import { useNavigate } from 'react-router'; import { useRecoilState } from 'recoil'; import ReviewZoneIcon from '@/assets/reviewZone.svg'; -import { Button } from '@/components'; -// TODO: ROUTE 상수명을 단수로 고치기 -import { ROUTE } from '@/constants/route'; +import { Button, ImgWithSkeleton } from '@/components'; +import { ROUTE } from '@/constants'; import { useGetReviewGroupData, useSearchParamAndQuery, useModals } from '@/hooks'; import { reviewRequestCodeAtom } from '@/recoil'; import { calculateParticle } from '@/utils'; @@ -16,6 +15,11 @@ import * as S from './styles'; const MODAL_KEYS = { content: 'CONTENT_MODAL', }; +const BUTTON_SIZE = { + width: '28rem', + height: '8.5rem', +}; +const IMG_HEIGHT = '15rem'; const ReviewZonePage = () => { const { isOpen, openModal, closeModal } = useModals(); @@ -47,29 +51,21 @@ const ReviewZonePage = () => { return ( - + + + {`${reviewGroupData.projectName}${calculateParticle({ target: reviewGroupData.projectName, particles: { withFinalConsonant: '을', withoutFinalConsonant: '를' } })} 함께한`} {`${reviewGroupData.revieweeName}의 리뷰 공간이에요`} - -