From 3a07b70fff825005b4f6e29a77a242e71229c5a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EA=B8=B8/KIM=20YOUNG=20GIL?= <80146176+Gilpop8663@users.noreply.github.com> Date: Wed, 19 Jul 2023 11:14:03 +0900 Subject: [PATCH 1/4] =?UTF-8?q?=EA=B3=B5=EC=9A=A9=20Select=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20UI=20=EA=B5=AC=ED=98=84=20(#58)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: (#43) 셀렉트 컴포넌트 UI 구현 스토리북 작성, 글로벌 CSS 설정, svg 파일 추가 * feat: (#43) 셀렉트 컴포넌트 사용 예시 스토리북 작성 셀렉트 부모에서 width 값을 지정해서 사용하도록 수정 * refactor: (#43) 코드 가독성을 위한 타입, 변수명 수정 * refactor: (#43) 셀렉트 컴포넌트에서 제네릭 타입을 받아서 사용하도록 수정 타입스크립트의 제네릭을 통해 안정성을 더하였음 --------- Co-authored-by: chsua <113416448+chsua@users.noreply.github.com> --- .../common/Select/Select.stories.tsx | 82 +++++++++++++++++++ .../src/components/common/Select/constants.ts | 3 + .../src/components/common/Select/index.tsx | 66 +++++++++++++++ .../src/components/common/Select/style.ts | 75 +++++++++++++++++ frontend/src/styles/globalStyle.ts | 1 + 5 files changed, 227 insertions(+) create mode 100644 frontend/src/components/common/Select/Select.stories.tsx create mode 100644 frontend/src/components/common/Select/constants.ts create mode 100644 frontend/src/components/common/Select/index.tsx create mode 100644 frontend/src/components/common/Select/style.ts diff --git a/frontend/src/components/common/Select/Select.stories.tsx b/frontend/src/components/common/Select/Select.stories.tsx new file mode 100644 index 000000000..f55a5fa1f --- /dev/null +++ b/frontend/src/components/common/Select/Select.stories.tsx @@ -0,0 +1,82 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { useState } from 'react'; + +import Select from '.'; + +const meta: Meta = { + component: Select, + decorators: [storyFn =>
{storyFn()}
], +}; + +export default meta; +type Story = StoryObj; + +const postStatus = ['all', 'progress', 'closed'] as const; +const sortingOption = ['popular', 'latest', 'longLong'] as const; + +type PostStatusType = (typeof postStatus)[number]; +type SortingOptionType = (typeof sortingOption)[number]; + +const MOCK_STATUS_OPTION: Record = { + all: '전체', + progress: '진행중', + closed: '마감완료', +}; + +const MOCK_SORTING_OPTION: Record = { + popular: '인기순', + latest: '최신순', + longLong: '엄청나게 긴 옵션', +}; + +export const PostStatus: Story = { + render: () => ( + + aria-label="게시글 진행 상태 선택" + selectedOption="진행중" + optionList={MOCK_STATUS_OPTION} + handleOptionChange={() => {}} + /> + ), +}; + +export const Sorting: Story = { + render: () => ( + {}} + /> + ), +}; + +export const SelectExample = () => { + const [selectedOption, setSelectedOption] = useState('popular'); + + const handelOptionChange = (option: SortingOptionType) => { + setSelectedOption(option); + }; + + return ( + + aria-label="게시글 정렬 방법 선택" + selectedOption={MOCK_SORTING_OPTION[selectedOption]} + optionList={MOCK_SORTING_OPTION} + handleOptionChange={handelOptionChange} + /> + ); +}; diff --git a/frontend/src/components/common/Select/constants.ts b/frontend/src/components/common/Select/constants.ts new file mode 100644 index 000000000..e95387f4a --- /dev/null +++ b/frontend/src/components/common/Select/constants.ts @@ -0,0 +1,3 @@ +export const SELECT_SELECTED = 'selected'; +export const SELECT_DISABLED = 'disabled'; +export const SELECT_DEFAULT = 'default'; diff --git a/frontend/src/components/common/Select/index.tsx b/frontend/src/components/common/Select/index.tsx new file mode 100644 index 000000000..4a809700e --- /dev/null +++ b/frontend/src/components/common/Select/index.tsx @@ -0,0 +1,66 @@ +import React, { useState } from 'react'; + +import chevronDown from '@assets/chevron-down.svg'; +import chevronUp from '@assets/chevron-up.svg'; + +import { SELECT_DEFAULT, SELECT_DISABLED, SELECT_SELECTED } from './constants'; +import * as S from './style'; + +export interface SelectProps + extends React.ButtonHTMLAttributes { + selectedOption: string; + optionList: Record; + handleOptionChange: (option: T) => void; + isDisabled?: boolean; +} + +export default function Select({ + selectedOption, + optionList, + handleOptionChange, + isDisabled = false, + ...rest +}: SelectProps) { + const optionKeyList = Object.keys(optionList) as T[]; + const [isOpen, setIsOpen] = useState(false); + + const toggleOpen = () => { + if (isDisabled) return; + setIsOpen(prev => !prev); + }; + + const handleSelectClick = (option: T) => { + handleOptionChange(option); + setIsOpen(false); + }; + + const getSelectStatus = () => { + if (isDisabled) { + return SELECT_DISABLED; + } + + if (isOpen) { + return SELECT_SELECTED; + } + + return SELECT_DEFAULT; + }; + + return ( + + + {selectedOption} + + + {isOpen && ( + + {optionKeyList.map(optionKey => ( + handleSelectClick(optionKey)}> + {optionList[optionKey]} + + ))} + + )} + + ); +} diff --git a/frontend/src/components/common/Select/style.ts b/frontend/src/components/common/Select/style.ts new file mode 100644 index 000000000..01b2aa814 --- /dev/null +++ b/frontend/src/components/common/Select/style.ts @@ -0,0 +1,75 @@ +import { styled } from 'styled-components'; + +import { theme } from '@styles/theme'; + +import { SELECT_DEFAULT, SELECT_DISABLED, SELECT_SELECTED } from './constants'; + +export const Container = styled.div` + font: var(--text-caption); + + @media (min-width: ${theme.breakpoint.sm}) { + font: var(--text-body); + } +`; + +const SELECTED_CSS_OPTION = { + selected: { + border: '2px solid #60a5fa', + color: 'var(--slate)', + cursor: 'pointer', + }, + disabled: { + border: '1px solid var(--slate)', + color: 'var(--slate)', + cursor: 'not-allowed', + }, + default: { + border: '1px solid var(--slate)', + color: '', + cursor: 'pointer', + }, +}; + +type Status = typeof SELECT_DEFAULT | typeof SELECT_DISABLED | typeof SELECT_SELECTED; + +export const SelectedContainer = styled.button<{ $status: Status }>` + display: flex; + justify-content: space-between; + align-items: center; + + width: 100%; + padding: 8px; + border: ${({ $status }) => SELECTED_CSS_OPTION[$status].border}; + border-radius: 4px; + + font: inherit; + + color: ${({ $status }) => SELECTED_CSS_OPTION[$status].color}; + + cursor: ${({ $status }) => SELECTED_CSS_OPTION[$status].cursor}; +`; + +export const OptionListContainer = styled.div` + display: flex; + flex-direction: column; + + margin-top: 4px; + border: 1px solid var(--slate); + border-radius: 4px; +`; + +export const OptionContainer = styled.div` + padding: 8px; + + cursor: pointer; + &:hover { + background-color: rgba(0, 0, 0, 0.1); + } +`; + +export const Image = styled.img<{ $isSelected: boolean }>` + width: 20px; + height: 20px; + border-left: 1px solid var(--slate); + padding-left: 8px; +`; diff --git a/frontend/src/styles/globalStyle.ts b/frontend/src/styles/globalStyle.ts index 138fcddfe..187577783 100644 --- a/frontend/src/styles/globalStyle.ts +++ b/frontend/src/styles/globalStyle.ts @@ -28,6 +28,7 @@ export const GlobalStyle = createGlobalStyle` :root { --primary-color: #FA7D7C; --white: #FFFFFF; + --slate: #94A3B8; --gray: #F4F4F4; --red: #F51A18; --dark-gray: #929292; From ddcc79bc7925976d6b333fc40f898d0b5cbf6a7b Mon Sep 17 00:00:00 2001 From: chsua <113416448+chsua@users.noreply.github.com> Date: Wed, 19 Jul 2023 15:00:09 +0900 Subject: [PATCH 2/4] =?UTF-8?q?=EA=B2=8C=EC=8B=9C=EA=B8=80=20=ED=86=B5?= =?UTF-8?q?=EA=B3=84=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=A0=9C?= =?UTF-8?q?=EC=9E=91=5FFeat/#65=20(#75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: (#65) msw를 위한 mock 게시물 상세정보 생성 * feat: (#65) 한 게시물 상세정보 fetch mocking * feat: (#65) 데이터, 로딩, 에러 정보 전달하는 fetch훅 생성 * feat: (#65) 게시물 하나 상세정보 fetch 함수 구현 * refactor: (#65) mockData 수정 * feat: (#65) 통계정보 get하는 api msw작성 * feat: (#65) 전체통계정보, 선택지 통계정보 get하는 fetch함수 작성 * refactor: (#65) useFetch 내 데이터이름 범용성 향성을 위해 수정 * feat: (#65) 통계그래프를 포함한 선택지 컴포넌트 생성 * test: (#65) 통계그래프를 포함한 선택지 컴포넌트 테스트 구현 * feat: (#65) 게시글 투표결과 통계 페이지 구현 * test: (#65) 게시글 투표결과 통계 페이지 테스트 구현 * feat: (#65) 로딩컴포넌트 구현 * test: (#65) 로딩컴포넌트 크기별 테스트 * refactor: 선택지변경 api 인자 interface 리팩터링 * style: (#65) 사용하지 않는 스타일컴포넌트 삭제 및 코드 정리 * feat: (#65) 통계컴포넌트에 로딩스피너 적용 * fix: (#65) 라디오 name속성이 공통되어 생긴 오작동 오류 수정 * refactor: ($65) map에 키 값 부여 * fix: 라디오에서 발생하는 checked 관련 오류 해결 - checked를 사용하는 경우 onChange 이벤트를 사용해야 함. - 때문에 defaultChecked로 수정하여 해결 * feat: (#65) 모바일 화면 외 크기에서는 헤더 감추기 * style: (#65) 로딩스피너 오타수정 * style: (#65) css 컨벤션에 따라 순서 수정 * refactor: (#65) 불필요한 코드 정리 - key와 value가 같다면 value 기재 생략 - useFetch 인자 수정 - msw 테스트 정리 - 안쓰는 코드 각주 삭제 * refactor: (#65) 목적에 맞지 않는 선택지 통계 토글 함수명 수정 * feat: (#54) 헤더에 있는 이전페이지로 가기 버튼 navigate 연결 * refactor: (#65) font-size를 var로 수정 * refactor: (#65) 통계 컴포넌트 라디오 상태명 변경 - 수정전: nowRadioMode - 수정후: currentRadioMode * refactor: (#65) 대소문자/오탈자 수정 --- frontend/src/api/sua/post.ts | 20 +++-- frontend/src/api/sua/voteResult.ts | 17 ++++ .../VoteStatistics/OneLineGraph/index.tsx | 2 +- .../VoteStatistics/TwoLineGraph/index.tsx | 2 +- .../src/components/VoteStatistics/index.tsx | 16 ++-- .../LoadingSpinner/LoadingSpinner.stories.tsx | 22 +++++ .../common/LoadingSpinner/index.tsx | 17 ++++ .../components/common/LoadingSpinner/style.ts | 44 ++++++++++ .../common/NarrowMainHeader/style.ts | 2 +- frontend/src/components/common/Post/index.tsx | 4 +- frontend/src/components/common/Post/style.ts | 4 +- frontend/src/hooks/useFetch.ts | 28 +++++++ frontend/src/mocks/handlers.ts | 3 +- frontend/src/mocks/mockData/voteDetail.json | 56 +++++++++++++ frontend/src/mocks/sua/getVoteDetail.ts | 19 +++++ .../OptionStatistics.stories.tsx | 29 +++++++ .../VoteStatistics/OptionStatistics/index.tsx | 61 ++++++++++++++ .../VoteStatistics/OptionStatistics/style.ts | 29 +++++++ .../VoteStatistics/VoteStatistics.stories.tsx | 14 ++++ frontend/src/pages/VoteStatistics/index.tsx | 80 +++++++++++++++++++ frontend/src/pages/VoteStatistics/style.ts | 37 +++++++++ 21 files changed, 484 insertions(+), 22 deletions(-) create mode 100644 frontend/src/api/sua/voteResult.ts create mode 100644 frontend/src/components/common/LoadingSpinner/LoadingSpinner.stories.tsx create mode 100644 frontend/src/components/common/LoadingSpinner/index.tsx create mode 100644 frontend/src/components/common/LoadingSpinner/style.ts create mode 100644 frontend/src/hooks/useFetch.ts create mode 100644 frontend/src/mocks/mockData/voteDetail.json create mode 100644 frontend/src/mocks/sua/getVoteDetail.ts create mode 100644 frontend/src/pages/VoteStatistics/OptionStatistics/OptionStatistics.stories.tsx create mode 100644 frontend/src/pages/VoteStatistics/OptionStatistics/index.tsx create mode 100644 frontend/src/pages/VoteStatistics/OptionStatistics/style.ts create mode 100644 frontend/src/pages/VoteStatistics/VoteStatistics.stories.tsx create mode 100644 frontend/src/pages/VoteStatistics/index.tsx create mode 100644 frontend/src/pages/VoteStatistics/style.ts diff --git a/frontend/src/api/sua/post.ts b/frontend/src/api/sua/post.ts index bb0d9f4c7..998d1c2ad 100644 --- a/frontend/src/api/sua/post.ts +++ b/frontend/src/api/sua/post.ts @@ -1,14 +1,22 @@ -import { patchFetch, postFetch } from '@utils/fetch'; +import { PostInfo } from '@type/post'; + +import { getFetch, patchFetch, postFetch } from '@utils/fetch'; export const votePost = async (postId: number, optionId: number) => { return await postFetch(`/posts/${postId}/options/${optionId}`, ''); }; -export const changeVotedOption = async ( - postId: number, - { originOptionId, newOptionId }: { originOptionId: number; newOptionId: number } -) => { +interface OptionData { + originOptionId: number; + newOptionId: number; +} + +export const changeVotedOption = async (postId: number, optionData: OptionData) => { return await patchFetch( - `/posts/${postId}/options?source=${originOptionId}&target=${newOptionId}` + `/posts/${postId}/options?source=${optionData.originOptionId}&target=${optionData.newOptionId}` ); }; + +export const getVoteDetail = async (postId: number): Promise => { + return await getFetch(`/posts/${postId}`); +}; diff --git a/frontend/src/api/sua/voteResult.ts b/frontend/src/api/sua/voteResult.ts new file mode 100644 index 000000000..d6bce671d --- /dev/null +++ b/frontend/src/api/sua/voteResult.ts @@ -0,0 +1,17 @@ +import { VoteResult } from '@components/VoteStatistics/type'; + +import { getFetch } from '@utils/fetch'; + +export const getPostStatistics = async (postId: number): Promise => { + return await getFetch(`/posts/${postId}/options`); +}; + +export const getOptionStatistics = async ({ + postId, + optionId, +}: { + postId: number; + optionId: number; +}): Promise => { + return await getFetch(`/posts/${postId}/options/${optionId}`); +}; diff --git a/frontend/src/components/VoteStatistics/OneLineGraph/index.tsx b/frontend/src/components/VoteStatistics/OneLineGraph/index.tsx index 65fd6b98e..158eb11d8 100644 --- a/frontend/src/components/VoteStatistics/OneLineGraph/index.tsx +++ b/frontend/src/components/VoteStatistics/OneLineGraph/index.tsx @@ -16,7 +16,7 @@ export default function OneLineGraph({ voteResult, size }: GraphProps) { const amount = Math.floor((voteResultFilteredByAge.total / maxVoteAmount) * 100); return ( - + {voteResultFilteredByAge.total} {voteResultFilteredByAge.name} diff --git a/frontend/src/components/VoteStatistics/TwoLineGraph/index.tsx b/frontend/src/components/VoteStatistics/TwoLineGraph/index.tsx index 00e2466cd..0fb36d861 100644 --- a/frontend/src/components/VoteStatistics/TwoLineGraph/index.tsx +++ b/frontend/src/components/VoteStatistics/TwoLineGraph/index.tsx @@ -15,7 +15,7 @@ export default function TwoLineGraph({ voteResult, size }: GraphProps) { const voteResultFilteredByAge = voteResult.age[option]; return ( - + {voteResultFilteredByAge.female} diff --git a/frontend/src/components/VoteStatistics/index.tsx b/frontend/src/components/VoteStatistics/index.tsx index 5931bc056..219107aaa 100644 --- a/frontend/src/components/VoteStatistics/index.tsx +++ b/frontend/src/components/VoteStatistics/index.tsx @@ -18,27 +18,29 @@ const radioMode: RadioMode = { type RadioCategory = keyof RadioMode; export default function VoteStatistics({ voteResult, size }: GraphProps) { - const [nowRadioMode, setNowRadioMode] = useState('all'); + const [currentRadioMode, setCurrentRadioMode] = useState('all'); const radioModeKey = Object.keys(radioMode) as RadioCategory[]; const changeMode = (e: MouseEvent) => { const target = e.target as HTMLInputElement; const targetCategory = target.value as RadioCategory; - setNowRadioMode(targetCategory); + setCurrentRadioMode(targetCategory); }; + const random = Date.now(); + return ( {radioModeKey.map(mode => { return ( - + {radioMode[mode]} @@ -46,8 +48,8 @@ export default function VoteStatistics({ voteResult, size }: GraphProps) { ); })} - {nowRadioMode === 'all' && } - {nowRadioMode === 'gender' && } + {currentRadioMode === 'all' && } + {currentRadioMode === 'gender' && } ); } diff --git a/frontend/src/components/common/LoadingSpinner/LoadingSpinner.stories.tsx b/frontend/src/components/common/LoadingSpinner/LoadingSpinner.stories.tsx new file mode 100644 index 000000000..2ac9165eb --- /dev/null +++ b/frontend/src/components/common/LoadingSpinner/LoadingSpinner.stories.tsx @@ -0,0 +1,22 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import LoadingSpinner from '.'; + +const meta: Meta = { + component: LoadingSpinner, +}; + +export default meta; +type Story = StoryObj; + +export const SizeS: Story = { + render: () => , +}; + +export const sizeM: Story = { + render: () => , +}; + +export const sizeL: Story = { + render: () => , +}; diff --git a/frontend/src/components/common/LoadingSpinner/index.tsx b/frontend/src/components/common/LoadingSpinner/index.tsx new file mode 100644 index 000000000..f8243d3e7 --- /dev/null +++ b/frontend/src/components/common/LoadingSpinner/index.tsx @@ -0,0 +1,17 @@ +import { Size } from '../AddButton/type'; + +import * as S from './style'; + +interface LoadingSpinnerProps { + size: Size; +} + +export default function LoadingSpinner({ size }: LoadingSpinnerProps) { + return ( + + + + + + ); +} diff --git a/frontend/src/components/common/LoadingSpinner/style.ts b/frontend/src/components/common/LoadingSpinner/style.ts new file mode 100644 index 000000000..d2f47673f --- /dev/null +++ b/frontend/src/components/common/LoadingSpinner/style.ts @@ -0,0 +1,44 @@ +import { keyframes, styled } from 'styled-components'; + +import { Size } from '../AddButton/type'; + +interface LoadingSpinnerProps { + $size: Size; +} + +const size = { + sm: '10px', + md: '15px', + lg: '30px', +}; + +const Animation = keyframes` +to { + transform: translate(0, -15px); +} +`; + +export const Container = styled.div` + display: flex; + justify-content: center; + align-items: center; + + & > :nth-child(2) { + animation-delay: 0.1s; + margin: 0 ${props => size[props.$size]}; + } + + & > :nth-child(3) { + animation-delay: 0.2s; + } +`; + +export const unit = styled.div` + width: ${props => size[props.$size]}; + height: ${props => size[props.$size]}; + border-radius: 50%; + + background-color: #747474; + + animation: ${Animation} 0.5s ease-in-out infinite alternate; +`; diff --git a/frontend/src/components/common/NarrowMainHeader/style.ts b/frontend/src/components/common/NarrowMainHeader/style.ts index ef5271a4a..d1c62ece6 100644 --- a/frontend/src/components/common/NarrowMainHeader/style.ts +++ b/frontend/src/components/common/NarrowMainHeader/style.ts @@ -15,7 +15,7 @@ export const Container = styled.div` background-color: #1f1f1f; - & :nth-child(2) { + & > :nth-child(2) { margin-right: auto; height: 60%; } diff --git a/frontend/src/components/common/Post/index.tsx b/frontend/src/components/common/Post/index.tsx index 3e81b346a..47735fceb 100644 --- a/frontend/src/components/common/Post/index.tsx +++ b/frontend/src/components/common/Post/index.tsx @@ -37,8 +37,8 @@ export default function Post({ postInfo, isPreview }: PostProps) { {writer.nickname} - {startTime} - {endTime} + {startTime} + {endTime} {content} diff --git a/frontend/src/components/common/Post/style.ts b/frontend/src/components/common/Post/style.ts index d43d171b3..d01e62a3d 100644 --- a/frontend/src/components/common/Post/style.ts +++ b/frontend/src/components/common/Post/style.ts @@ -50,7 +50,7 @@ export const Wrapper = styled.div` font-size: 1.2rem; - :nth-child(2) { + & > :nth-child(2) { margin-left: 10px; } @@ -59,8 +59,6 @@ export const Wrapper = styled.div` } `; -export const Time = styled.span``; - export const Content = styled.p<{ $isPreview: boolean }>` display: -webkit-box; diff --git a/frontend/src/hooks/useFetch.ts b/frontend/src/hooks/useFetch.ts new file mode 100644 index 000000000..e0b526791 --- /dev/null +++ b/frontend/src/hooks/useFetch.ts @@ -0,0 +1,28 @@ +import { useEffect, useState } from 'react'; + +export const useFetch = (fetchFn: () => Promise) => { + const [data, setData] = useState(); + const [errorMessage, setErrorMessage] = useState(); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + fetchFn() + .then(res => { + setData(res); + }) + .catch(rej => { + setErrorMessage(rej.message); + }) + .finally(() => { + setIsLoading(false); + }); + + return (() => { + setData(undefined); + setIsLoading(true); + setErrorMessage(undefined); + })(); + }, []); + + return { data, errorMessage, isLoading }; +}; diff --git a/frontend/src/mocks/handlers.ts b/frontend/src/mocks/handlers.ts index 5368f1e53..42b745f7d 100644 --- a/frontend/src/mocks/handlers.ts +++ b/frontend/src/mocks/handlers.ts @@ -1,4 +1,5 @@ import { example } from './example/get'; +import { getVoteDetailTest } from './sua/getVoteDetail'; import { votePostTest } from './sua/vote'; -export const handlers = [...example, ...votePostTest]; +export const handlers = [...example, ...votePostTest, ...getVoteDetailTest]; diff --git a/frontend/src/mocks/mockData/voteDetail.json b/frontend/src/mocks/mockData/voteDetail.json new file mode 100644 index 000000000..581b9e630 --- /dev/null +++ b/frontend/src/mocks/mockData/voteDetail.json @@ -0,0 +1,56 @@ +{ + "postId": 1, + "title": "어느 곳에서 정보를 찾아야 할지도 막막한 사람들을 위한, 심심풀이로 나의 취향과 남의 취향을 비교해보고 싶은 사람들을 위한 프로젝트", + "writer": { + "id": 12121221, + "nickname": "우아한 잔치국수" + }, + "content": "이는 사람들에게 재미와 정보, 두 가지를 줄 수 있습니다. 사람들은 MBTI, 밸런스게임처럼 나와 같은 사람들을 찾고, 나와 다른 사람들과 비교하는 것을 즐깁니다. 이를 컨텐츠화하여 보다 빠르게 질문하고 답변하며, 사람들의 반응을 확인할 수 있다면 사람들은 충분한 즐거움을 느낄 것입니다. 또한 20대가 많은 대학가에 창업을 하고 싶지만 20대의 의견을 모르겠을 때, 확실한 답은 아닐지라도 어느 정도의 가이드를 줄 수 있을 것입니다. 질문자에게 제공되는 성별/나이대별 투표 결과 정보는 질문자가 하고자 하는 의사결정의 근거가 될 수 있을 것입니다.", + "category": [ + { + "id": 76767, + "name": "개발" + }, + { + "id": 74632, + "name": "연애" + }, + { + "id": 71347, + "name": "상담" + } + ], + "startTime": "2023-07-12 12:40", + "endTime": "2023-07-13 18:40", + "voteInfo": { + "selectedOptionId": 2, + "allPeopleCount": 123, + "options": [ + { + "id": 12, + "text": "당선", + "peopleCount": 30, + "percent": 30 + }, + { + "id": 123, + "text": "votogether", + "peopleCount": 40, + "percent": 40 + }, + { + "id": 1234, + "text": "인스타그램, 블라인드와 같은 SNS의 형식을 차용합니다. 누군가는 글을 쓰고, 누군가는 반응합니다. 다만, 댓글은 없습니다. 투표로 자신의 의견을 표현하고 이를 사람들에게 보여줍니다.", + "peopleCount": 20, + "percent": 20 + }, + { + "id": 2, + "text": "fun from choice, 오늘도 즐거운 한 표 ", + "imageUrl": "https://source.unsplash.com/random", + "peopleCount": 10, + "percent": 10 + } + ] + } +} diff --git a/frontend/src/mocks/sua/getVoteDetail.ts b/frontend/src/mocks/sua/getVoteDetail.ts new file mode 100644 index 000000000..feb545d3e --- /dev/null +++ b/frontend/src/mocks/sua/getVoteDetail.ts @@ -0,0 +1,19 @@ +import { rest } from 'msw'; + +import { mockVoteResult } from '@components/VoteStatistics/mockData'; + +import voteDetail from '../mockData/voteDetail.json'; + +export const getVoteDetailTest = [ + rest.get(`/posts/:postId`, (req, res, ctx) => { + return res(ctx.status(200), ctx.delay(1000), ctx.json(voteDetail)); + }), + + rest.get(`/posts/:postId/options`, (req, res, ctx) => { + return res(ctx.status(200), ctx.delay(1000), ctx.json(mockVoteResult)); + }), + + rest.get(`/posts/:postId/options/:optionId`, (req, res, ctx) => { + return res(ctx.status(200), ctx.delay(1000), ctx.json(mockVoteResult)); + }), +]; diff --git a/frontend/src/pages/VoteStatistics/OptionStatistics/OptionStatistics.stories.tsx b/frontend/src/pages/VoteStatistics/OptionStatistics/OptionStatistics.stories.tsx new file mode 100644 index 000000000..1ef35c74e --- /dev/null +++ b/frontend/src/pages/VoteStatistics/OptionStatistics/OptionStatistics.stories.tsx @@ -0,0 +1,29 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import OptionStatistics from '.'; + +const meta: Meta = { + component: OptionStatistics, +}; + +export default meta; +type Story = StoryObj; + +const MOCK_MAX_VOTE_OPTION = { + id: 2, + text: '', + imageUrl: 'https://source.unsplash.com/random', + peopleCount: 10, + percent: 10, +}; + +export const defaultPage: Story = { + render: () => ( + + ), +}; diff --git a/frontend/src/pages/VoteStatistics/OptionStatistics/index.tsx b/frontend/src/pages/VoteStatistics/OptionStatistics/index.tsx new file mode 100644 index 000000000..169ac23c5 --- /dev/null +++ b/frontend/src/pages/VoteStatistics/OptionStatistics/index.tsx @@ -0,0 +1,61 @@ +import { useState } from 'react'; + +import { WrittenVoteOptionType } from '@type/post'; + +import { useFetch } from '@hooks/useFetch'; + +import { getOptionStatistics } from '@api/sua/voteResult'; + +import { Size } from '@components/common/AddButton/type'; +import LoadingSpinner from '@components/common/LoadingSpinner'; +import WrittenVoteOption from '@components/optionList/WrittenVoteOptionList/WrittenVoteOption'; +import VoteStatistics from '@components/VoteStatistics'; + +import * as S from './style'; + +interface OptionStatisticsProps { + postId: number; + isSelectedOption: boolean; + voteOption: WrittenVoteOptionType; + size: Size; +} + +export default function OptionStatistics({ + postId, + voteOption, + isSelectedOption, + size, +}: OptionStatisticsProps) { + const [isStatisticsOpen, setIsStatisticsOpen] = useState(false); + const { + data: voteResult, + errorMessage, + isLoading, + } = useFetch(() => getOptionStatistics({ postId, optionId: voteOption.id })); + + const toggleOptionStatistics = () => { + setIsStatisticsOpen(!isStatisticsOpen); + }; + + return ( + + + + {isStatisticsOpen && voteResult && } + {isStatisticsOpen && isLoading && ( + + + + )} + {isStatisticsOpen && errorMessage} + + + ); +} diff --git a/frontend/src/pages/VoteStatistics/OptionStatistics/style.ts b/frontend/src/pages/VoteStatistics/OptionStatistics/style.ts new file mode 100644 index 000000000..d2094b644 --- /dev/null +++ b/frontend/src/pages/VoteStatistics/OptionStatistics/style.ts @@ -0,0 +1,29 @@ +import { styled } from 'styled-components'; + +export const Container = styled.div` + display: flex; + flex-direction: column; + + width: 95%; + border-radius: 10px; + + background-color: #f6f6f6; + + font-size: var(--text-title); +`; + +export const StatisticsContainer = styled.div` + display: flex; + align-items: center; + justify-content: center; + + & > * { + padding: 30px; + } +`; + +export const LoadingWrapper = styled.div` + display: flex; + + height: 100px; +`; diff --git a/frontend/src/pages/VoteStatistics/VoteStatistics.stories.tsx b/frontend/src/pages/VoteStatistics/VoteStatistics.stories.tsx new file mode 100644 index 000000000..2b0d82fa5 --- /dev/null +++ b/frontend/src/pages/VoteStatistics/VoteStatistics.stories.tsx @@ -0,0 +1,14 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import VoteStatisticsPage from '.'; + +const meta: Meta = { + component: VoteStatisticsPage, +}; + +export default meta; +type Story = StoryObj; + +export const DefaultPage: Story = { + render: () => , +}; diff --git a/frontend/src/pages/VoteStatistics/index.tsx b/frontend/src/pages/VoteStatistics/index.tsx new file mode 100644 index 000000000..957b3ec9b --- /dev/null +++ b/frontend/src/pages/VoteStatistics/index.tsx @@ -0,0 +1,80 @@ +import { useLocation, useNavigate } from 'react-router-dom'; + +import { useFetch } from '@hooks/useFetch'; + +import { getVoteDetail } from '@api/sua/post'; +import { getPostStatistics } from '@api/sua/voteResult'; + +import IconButton from '@components/common/IconButton'; +import LoadingSpinner from '@components/common/LoadingSpinner'; +import NarrowTemplateHeader from '@components/common/NarrowTemplateHeader'; +import VoteStatistics from '@components/VoteStatistics'; + +import OptionStatistics from './OptionStatistics'; +import * as S from './style'; + +export default function VoteStatisticsPage() { + // const location = useLocation(); + // const postId = location.state.id; + const postId = 1; + + const navigate = useNavigate(); + + const { + data: postDetail, + errorMessage: postError, + isLoading: isPostLoading, + } = useFetch(() => getVoteDetail(postId)); + const { + data: voteResult, + errorMessage: voteResultError, + isLoading: isVoteResultLoading, + } = useFetch(() => getPostStatistics(postId)); + + const movePostDetailPage = () => { + navigate(`/posts/${postId}`); + }; + + return ( + <> + + + + + + + 투표 통계 + {postError &&
{postError}
} + {isPostLoading && ( + + + + )} + {postDetail && ( + + {voteResultError &&
{voteResultError}
} + {isVoteResultLoading && ( + + + + )} + {voteResult && } + + {postDetail.voteInfo.options.map(option => { + const { postId, voteInfo } = postDetail; + return ( + + ); + })} +
+ )} +
+ + ); +} diff --git a/frontend/src/pages/VoteStatistics/style.ts b/frontend/src/pages/VoteStatistics/style.ts new file mode 100644 index 000000000..24cc6e830 --- /dev/null +++ b/frontend/src/pages/VoteStatistics/style.ts @@ -0,0 +1,37 @@ +import { styled } from 'styled-components'; + +import { theme } from '@styles/theme'; + +export const Container = styled.div` + display: flex; + flex-direction: column; + align-items: center; + + margin-top: 50px; + margin-bottom: 20px; +`; + +export const HeaderWrapper = styled.div` + @media (min-width: ${theme.breakpoint.sm}) { + display: none; + } +`; + +export const PageHeader = styled.div` + margin: 15px; + + font-size: 20px; +`; + +export const OptionContainer = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 15px; +`; + +export const LoadingWrapper = styled.div` + display: flex; + + height: 100px; +`; From 341b55db5584780fc0d0de33db4bc6b4d56379fb Mon Sep 17 00:00:00 2001 From: jero_kang <81199414+inyeong-kang@users.noreply.github.com> Date: Wed, 19 Jul 2023 19:08:47 +0900 Subject: [PATCH 3/4] =?UTF-8?q?=EA=B8=80=20=EC=9E=91=EC=84=B1=20=EB=B0=8F?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=20(#72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: (#55) npm run dev 실행 시 발생하는 오류 해결, 라우팅 이슈 해결 * feat: (#55) 옵션에 따라 마감 시간을 가공하여 반환하는 함수 구현 * feat: (#55) 컴포넌트를 열고 닫는 커스텀 훅 구현 * feat: (#55) 글 작성 및 수정 관련 API, 커스텀 쿼리 훅 구현 * feat: (#55) 글 작성 및 수정 폼 구현 * feat: (#55) 글 작성 및 수정 페이지 구현, 페이지 라우팅 구현 * feat: (#55) 글 수정을 위해 url 파라미터를 가져오는 로직 구현 * refactor: (#55) 선택지 TextArea, FileInput 에 name 속성 추가 * refactor: (#55) 선택지 TextArea, FileInput 에 name 속성 변경 * feat: (#55) 글 작성/수정 폼에 이미지 파일 옵션 추가 * feat: (#55) msw로 글 작성/수정 API 모킹 * fix: (#55) form 태그 내에서 임의의 button 누르면 submit 이벤트가 일어나는 오류 해결 submit을 위해 만든 button이 아닌 경우, type='button' 속성을 추가함 * feat: (#55) useMutation 함수 반환값으로 isLoading, isError, error 추가 * feat: (#55) query key 상수화 * fix: (#55) Uncaught SyntaxError: Unexpected token ' in JSON 에러 해결 handler 함수들의 반환 값에 ctx.json 추가 * fix: (#55) 이미지 업로드 후 그림 버튼이 렌더링되는 이슈 해결 * feat: (#55) multipart 데이터 경우에 대한 fetch 함수 구현 * chore: (#55) 불필요한 name 속성 삭제 * chore: (#55) props 추가 * refactor: (#55) PostForm의 data props를 기존의 PostInfo 타입으로 변경 * refactor: (#55) request로 보낼 데이터의 타입을 FormData로 변경 * refactor: (#55) mocking 함수 url, 상태 코드 수정 * fix: (#55) 작성시간인 startTime의 유무에 따라 now값을 선언하여 Invalid Date 에러 해결 * refactor: (#55) mutate props 타입 좁히기 * refactor: (#55) 기준 시간에 마감 시간 옵션을 더해 마감 기한을 반환하는 함수 리팩터링 직관적인 함수명으로 변경 utils/post 로 파일 이동 데이터의 내용을 잘 드러내는 파라미터 이름으로 변경 * refactor: (#55) queryKey 객체의 키 값 대문자로 수정 * refactor: (#55) PostForm 컴포넌트 self-closing-tag 로 변경 * chore: (#55) 불필요한 파일 삭제 * feat: (#55) API 통신 중 에러의 경우 에 대한 처리 추가 * refactor: (#55) error 객체를 props에 추가하여 에러 메시지를 보여주도록 수정 * refactor: (#55) styled component 변수명 수정 * refactor: (#55) onError에서 error 객체 콘솔에 출력 * feat: (#55) 구체적인 마감 시간에 대한 설명 컴포넌트 추가 * feat: (#55) input 또는 textarea를 제어하는 커스텀 훅 구현 * design: (#55) OptionListWrapper css 수정, 반응형 구현 * chore: (#55) 불필요한 코드 삭제 --- frontend/package-lock.json | 75 +++---- frontend/src/App.tsx | 13 +- frontend/src/api/jero/post.ts | 9 + .../components/PostForm/PostForm.stories.tsx | 75 +++++++ frontend/src/components/PostForm/constants.ts | 1 + frontend/src/components/PostForm/index.tsx | 197 ++++++++++++++++++ frontend/src/components/PostForm/style.ts | 162 ++++++++++++++ .../OptionUploadImageButton.stories.tsx | 2 +- .../OptionUploadImageButton/index.tsx | 4 +- .../OptionUploadImageButton/style.ts | 3 +- .../WritingVoteOption/index.tsx | 33 ++- .../WritingVoteOptionList/index.tsx | 2 +- frontend/src/constants/queryKey.ts | 3 + .../src/hooks/query/post/useCreatePost.ts | 18 ++ frontend/src/hooks/query/post/useEditPost.ts | 21 ++ frontend/src/hooks/useText.ts | 23 ++ frontend/src/hooks/useToggle.tsx | 15 ++ frontend/src/mocks/handlers.ts | 5 +- frontend/src/mocks/jero/post.ts | 25 +++ frontend/src/pages/post/CreatePost/index.tsx | 13 ++ frontend/src/pages/post/EditPost/index.tsx | 62 ++++++ frontend/src/pages/post/WritePost.tsx | 5 - frontend/src/routes/router.tsx | 11 +- frontend/src/utils/fetch.ts | 37 ++++ frontend/src/utils/post/formatTime.ts | 32 +++ 25 files changed, 761 insertions(+), 85 deletions(-) create mode 100644 frontend/src/api/jero/post.ts create mode 100644 frontend/src/components/PostForm/PostForm.stories.tsx create mode 100644 frontend/src/components/PostForm/constants.ts create mode 100644 frontend/src/components/PostForm/index.tsx create mode 100644 frontend/src/components/PostForm/style.ts create mode 100644 frontend/src/constants/queryKey.ts create mode 100644 frontend/src/hooks/query/post/useCreatePost.ts create mode 100644 frontend/src/hooks/query/post/useEditPost.ts create mode 100644 frontend/src/hooks/useText.ts create mode 100644 frontend/src/hooks/useToggle.tsx create mode 100644 frontend/src/mocks/jero/post.ts create mode 100644 frontend/src/pages/post/CreatePost/index.tsx create mode 100644 frontend/src/pages/post/EditPost/index.tsx delete mode 100644 frontend/src/pages/post/WritePost.tsx create mode 100644 frontend/src/utils/post/formatTime.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 76d23211f..3b018a65d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -22959,8 +22959,7 @@ "version": "7.21.0-placeholder-for-preset-env.2", "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", - "dev": true, - "requires": {} + "dev": true }, "@babel/plugin-proposal-unicode-property-regex": { "version": "7.18.6", @@ -24054,8 +24053,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.0.1.tgz", "integrity": "sha512-jT/qyKZ9rzLErtrjGgdkMBn2OP8wl0G3sQlBb3YPryvKHsjvINUhVaPFfP+fpBcOkmrVOVEEHQFJ7nbj2TH2gw==", - "dev": true, - "requires": {} + "dev": true }, "@esbuild/android-arm": { "version": "0.17.19", @@ -25159,8 +25157,7 @@ "version": "3.5.2", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true, - "requires": {} + "dev": true }, "find-up": { "version": "5.0.0", @@ -26979,8 +26976,7 @@ "version": "7.0.26", "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-7.0.26.tgz", "integrity": "sha512-heobG4IovYAD9fo7qmUHylCSQjDd1eXDCOaTiy+XVKobHAJgkz1gKqbaFSP6KLkPE4cKyScku2K9mY0tcKIhMw==", - "dev": true, - "requires": {} + "dev": true }, "@storybook/react-webpack5": { "version": "7.0.26", @@ -28280,22 +28276,19 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-2.1.1.tgz", "integrity": "sha512-wy0mglZpDSiSS0XHrVR+BAdId2+yxPSoJW8fsna3ZpYSlufjvxnP4YbKTCBZnNIcGN4r6ZPXV55X4mYExOfLmw==", - "dev": true, - "requires": {} + "dev": true }, "@webpack-cli/info": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@webpack-cli/info/-/info-2.0.2.tgz", "integrity": "sha512-zLHQdI/Qs1UyT5UBdWNqsARasIA+AaF8t+4u2aS2nEpBQh2mWIVb8qAklq0eUENnC5mOItrIB4LiS9xMtph18A==", - "dev": true, - "requires": {} + "dev": true }, "@webpack-cli/serve": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@webpack-cli/serve/-/serve-2.0.5.tgz", "integrity": "sha512-lqaoKnRYBdo1UgDX8uF24AfGMifWK19TxPmM5FHc2vAGxrJ/qtyUyFBWoY1tISZdelsQ5fBcOusifo5o5wSJxQ==", - "dev": true, - "requires": {} + "dev": true }, "@xmldom/xmldom": { "version": "0.8.9", @@ -28375,15 +28368,13 @@ "version": "1.9.0", "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.9.0.tgz", "integrity": "sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==", - "dev": true, - "requires": {} + "dev": true }, "acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "requires": {} + "dev": true }, "acorn-walk": { "version": "7.2.0", @@ -28687,8 +28678,7 @@ "version": "7.0.0-bridge.0", "resolved": "https://registry.npmjs.org/babel-core/-/babel-core-7.0.0-bridge.0.tgz", "integrity": "sha512-poPX9mZH/5CSanm50Q+1toVci6pv5KSRv/5TWCwtzQS5XEwn40BcCrgIeMFWP9CKKIniKXNxoIOnOq4VVlGXhg==", - "dev": true, - "requires": {} + "dev": true }, "babel-jest": { "version": "29.6.0", @@ -30761,8 +30751,7 @@ "version": "8.8.0", "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.8.0.tgz", "integrity": "sha512-wLbQiFre3tdGgpDv67NQKnJuTlcUVYHas3k+DZCc2U2BadthoEY4B7hLPvAxaqdyOGCzuLfii2fqGph10va7oA==", - "dev": true, - "requires": {} + "dev": true }, "eslint-config-react-app": { "version": "7.0.1", @@ -30969,8 +30958,7 @@ "version": "4.6.0", "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.0.tgz", "integrity": "sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g==", - "dev": true, - "requires": {} + "dev": true }, "eslint-plugin-storybook": { "version": "0.6.12", @@ -31555,8 +31543,7 @@ "version": "3.5.2", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true, - "requires": {} + "dev": true }, "ansi-styles": { "version": "4.3.0", @@ -32316,8 +32303,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", - "dev": true, - "requires": {} + "dev": true }, "ieee754": { "version": "1.2.1", @@ -33964,8 +33950,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", - "dev": true, - "requires": {} + "dev": true }, "jest-regex-util": { "version": "29.4.3", @@ -35162,8 +35147,7 @@ "version": "7.2.1", "resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-7.2.1.tgz", "integrity": "sha512-9HrdzBAo0+sFz9ZYAGT5fB8ilzTW+q6lPocRxrIesMO+aB40V9MgFfbfMXxlGjf22OpRy+IXlvVaQenicdpgbg==", - "dev": true, - "requires": {} + "dev": true }, "mdast-util-definitions": { "version": "4.0.0", @@ -36121,8 +36105,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.0.0.tgz", "integrity": "sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw==", - "dev": true, - "requires": {} + "dev": true }, "postcss-modules-local-by-default": { "version": "4.0.3", @@ -36484,8 +36467,7 @@ "version": "5.6.1", "resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.6.1.tgz", "integrity": "sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==", - "dev": true, - "requires": {} + "dev": true }, "react-docgen": { "version": "5.4.3", @@ -36520,8 +36502,7 @@ "version": "2.2.2", "resolved": "https://registry.npmjs.org/react-docgen-typescript/-/react-docgen-typescript-2.2.2.tgz", "integrity": "sha512-tvg2ZtOpOi6QDwsb3GZhOjDkkX0h8Z2gipvTg6OVMUyoYoURhEiRNePT8NZItTVCDh39JJHnLdfCOkzoLbFnTg==", - "dev": true, - "requires": {} + "dev": true }, "react-dom": { "version": "18.2.0", @@ -36561,8 +36542,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/react-inspector/-/react-inspector-6.0.2.tgz", "integrity": "sha512-x+b7LxhmHXjHoU/VrFAzw5iutsILRoYyDq97EDYdFpPLcvqtEzk4ZSZSQjnFPbr5T57tLXnHcqFYoN1pI6u8uQ==", - "dev": true, - "requires": {} + "dev": true }, "react-is": { "version": "16.13.1", @@ -37558,8 +37538,7 @@ "version": "3.3.3", "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-3.3.3.tgz", "integrity": "sha512-53BiGLXAcll9maCYtZi2RCQZKa8NQQai5C4horqKyRmHj9H7QmcUyucrH+4KW/gBQbXM2AsB0axoEcFZPlfPcw==", - "dev": true, - "requires": {} + "dev": true }, "styled-components": { "version": "6.0.2", @@ -37824,8 +37803,7 @@ "version": "3.5.2", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true, - "requires": {} + "dev": true }, "json-schema-traverse": { "version": "0.4.1", @@ -38421,8 +38399,7 @@ "use-sync-external-store": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", - "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", - "requires": {} + "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==" }, "util": { "version": "0.12.5", @@ -38598,8 +38575,7 @@ "version": "3.5.2", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true, - "requires": {} + "dev": true }, "json-schema-traverse": { "version": "0.4.1", @@ -38908,8 +38884,7 @@ "version": "8.13.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.13.0.tgz", "integrity": "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==", - "dev": true, - "requires": {} + "dev": true }, "xml-name-validator": { "version": "4.0.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a1aebee5e..a935faf9f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,15 +1,22 @@ import { RouterProvider } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ThemeProvider } from 'styled-components'; import router from '@routes/router'; +import { GlobalStyle } from '@styles/globalStyle'; +import { theme } from '@styles/theme'; + const queryClient = new QueryClient(); const App = () => ( - - - + + + + + + ); export default App; diff --git a/frontend/src/api/jero/post.ts b/frontend/src/api/jero/post.ts new file mode 100644 index 000000000..292170caf --- /dev/null +++ b/frontend/src/api/jero/post.ts @@ -0,0 +1,9 @@ +import { multiPutFetch, multiPostFetch } from '@utils/fetch'; + +export const createPost = async (newPost: FormData) => { + return await multiPostFetch('/posts', newPost); +}; + +export const editPost = async (postId: number, updatedPost: FormData) => { + return await multiPutFetch(`/posts/${postId}`, updatedPost); +}; diff --git a/frontend/src/components/PostForm/PostForm.stories.tsx b/frontend/src/components/PostForm/PostForm.stories.tsx new file mode 100644 index 000000000..0285247e7 --- /dev/null +++ b/frontend/src/components/PostForm/PostForm.stories.tsx @@ -0,0 +1,75 @@ +import type { Meta } from '@storybook/react'; + +import { PostInfo } from '@type/post'; + +import { useCreatePost } from '@hooks/query/post/useCreatePost'; +import { useEditPost } from '@hooks/query/post/useEditPost'; + +import PostForm from '.'; + +const meta: Meta = { + component: PostForm, +}; + +export default meta; + +const MOCK_DATA: PostInfo = { + postId: 1, + title: '당신의 최애 동물에 투표하세요!', + writer: { id: 15216, nickname: 'jero' }, + content: + '한자로 견(犬)·구(狗) 등으로 표기한다. 포유류 중 가장 오래된 가축으로 거의 전세계에서 사육되며 약 400여 품종이 있다. 개는 이리·자칼(jackal) 등이 조상이라고 하는데, 이는 개와 교배하여 계대(繼代) 번식의 가능성이 있는 새끼를 낳을 수 있다는 것을 뜻한다. 즉 개에 이들의 혈액이 혼혈될 가능성이 있다는 것이다. 그러나 두개골이나 치아의 구조를 보면 개는 혼합된 것이 아니며, 또 그들 중의 어느 것에서 생긴 것이라고도 여겨지지 않는다. 아마도 개는 오스트레일리아에 야생하는 딩고(dingo)나 남아시아에 반야생상태로 서식하는 개와 흡사한, 절멸된 야생종에서 생긴 것으로 추측된다.', + category: [ + { id: 13215, name: '음식' }, + { id: 13217, name: '게임' }, + { id: 13219, name: '연예' }, + ], + startTime: '2023-07-18 12:40', + endTime: '2023-08-15 12:40', + voteInfo: { + selectedOptionId: 1, + allPeopleCount: 0, + options: [ + { + id: Math.floor(Math.random() * 100000), + text: '햄스터가 세상을 구한다.', + imageUrl: '', + peopleCount: 0, + percent: 20, + }, + { + id: Math.floor(Math.random() * 100000), + text: '강아지가 세상을 구한다.', + imageUrl: '', + peopleCount: 0, + percent: 10, + }, + { + id: Math.floor(Math.random() * 100000), + text: '고양이가 세상을 구한다.', + imageUrl: 'https://source.unsplash.com/random', + peopleCount: 0, + percent: 10, + }, + ], + }, +}; + +export const NewPost = () => { + const { mutate, isError, error } = useCreatePost(); + return ( + <> + + + ); +}; + +export const OldPost = () => { + const examplePostId = 1; + const { mutate, isError, error } = useEditPost(examplePostId); + return ( + <> + + + ); +}; diff --git a/frontend/src/components/PostForm/constants.ts b/frontend/src/components/PostForm/constants.ts new file mode 100644 index 000000000..9cc543569 --- /dev/null +++ b/frontend/src/components/PostForm/constants.ts @@ -0,0 +1 @@ +export const DEADLINE_OPTION = ['10분', '30분', '1시간', '6시간', '1일']; diff --git a/frontend/src/components/PostForm/index.tsx b/frontend/src/components/PostForm/index.tsx new file mode 100644 index 000000000..5cb6dce82 --- /dev/null +++ b/frontend/src/components/PostForm/index.tsx @@ -0,0 +1,197 @@ +import type { UseMutateFunction } from '@tanstack/react-query'; + +import React, { HTMLAttributes, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { PostInfo } from '@type/post'; + +import { useText } from '@hooks/useText'; +import { useToggle } from '@hooks/useToggle'; + +import Modal from '@components/common/Modal'; +import NarrowTemplateHeader from '@components/common/NarrowTemplateHeader'; +import SquareButton from '@components/common/SquareButton'; +import TimePickerOptionList from '@components/common/TimePickerOptionList'; +import WritingVoteOptionList from '@components/optionList/WritingVoteOptionList'; + +import { addTimeToDate, formatTimeWithOption } from '@utils/post/formatTime'; + +import { DEADLINE_OPTION } from './constants'; +import * as S from './style'; + +interface PostFormProps extends HTMLAttributes { + data?: PostInfo; + mutate: UseMutateFunction; + isError: boolean; + error: unknown; +} + +const MAX_TITLE_LENGTH = 100; +const MAX_CONTENT_LENGTH = 1000; + +export default function PostForm({ data, mutate, isError, error }: PostFormProps) { + const { + title, + content, + category: categoryIds, + startTime, + endTime: deadline, + voteInfo, + } = data ?? {}; + + const navigate = useNavigate(); + + const { isOpen, openComponent, closeComponent } = useToggle(); + const [time, setTime] = useState({ + day: 0, + hour: 0, + minute: 0, + }); + const baseTime = startTime ? new Date(startTime) : new Date(); + + const { text: writingTitle, handleTextChange: handleTitleChange } = useText(title ?? ''); + const { text: writingContent, handleTextChange: handleContentChange } = useText(content ?? ''); + + const handleDeadlineButtonClick = (option: string) => { + setTime(formatTimeWithOption(option)); + }; + + const handleResetBUtton = () => { + if (window.confirm('정말 초기화하시겠습니까?')) { + const updatedTime = { + day: 0, + hour: 0, + minute: 0, + }; + setTime(updatedTime); + } + }; + + const handlePostFormSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const formData = new FormData(); + + if (e.target instanceof HTMLFormElement) { + const optionImageFileInputs = + e.target.querySelectorAll('input[type="file"]'); + const fileInputList: HTMLInputElement[] = [...optionImageFileInputs]; + const imageFileList: File[] = []; + fileInputList.forEach(item => { + if (item.files) { + imageFileList.push(item.files[0]); + } + }); + + imageFileList.map(file => formData.append('images', file)); + + const optionTextAreas = e.target.querySelectorAll('textarea[name="optionText"]'); + const writingOptionTexts = Array.from(optionTextAreas).map((textarea: any) => textarea.value); + + const updatedPostTexts = { + categoryIds: [1, 2], // 다중 선택 컴포넌트 구현 후 수정 예정 + title: writingTitle ?? '', + content: writingContent ?? '', + postOptions: writingOptionTexts, + deadline: addTimeToDate(time, baseTime), + // 글 수정의 경우 작성시간을 기준으로 마감시간 옵션을 더한다. + // 마감시간 옵션을 선택 안했다면 기존의 마감 시간을 유지한다. + }; + formData.append('texts', JSON.stringify(updatedPostTexts)); + + mutate(formData); + + if (isError && error instanceof Error) { + alert(error.message); + return; + } + + navigate('/'); + } + }; + + return ( + <> + + navigate('/')}>취소 + + 저장 + + + + + + + ) => handleTitleChange(e, 100)} + placeholder="제목을 입력해주세요" + maxLength={MAX_TITLE_LENGTH} + required + /> + ) => handleContentChange(e, 1000)} + placeholder="내용을 입력해주세요" + maxLength={MAX_CONTENT_LENGTH} + required + /> + + + + + + + {time.day}일 {time.hour}시 {time.minute}분 후에 마감됩니다. + + {data && ( + + 글 작성일({startTime})로부터 하루 이후 ( + {addTimeToDate({ day: 1, hour: 0, minute: 0 }, baseTime)})까지만 선택 가능합니다. + + )} + {data && * 기존 마감 시간은 {deadline}입니다. } + + {DEADLINE_OPTION.map(option => ( + handleDeadlineButtonClick(option)} + theme="blank" + > + {option} + + ))} + + 사용자 지정 + + + + + {isOpen && ( + + <> + +

마감 시간 선택

+ X +
+ + 최대 3일을 넘을 수 없습니다. + + + + 초기화 + + + + +
+ )} +
+ + ); +} diff --git a/frontend/src/components/PostForm/style.ts b/frontend/src/components/PostForm/style.ts new file mode 100644 index 000000000..01a27578f --- /dev/null +++ b/frontend/src/components/PostForm/style.ts @@ -0,0 +1,162 @@ +import { styled } from 'styled-components'; + +export const HeaderButton = styled.button` + width: 30px; + + color: white; + + cursor: pointer; +`; + +export const Form = styled.form` + display: flex; + flex-direction: column; + justify-content: start; + gap: 20px; +`; + +export const Wrapper = styled.div` + display: flex; + flex-direction: column; + justify-content: start; + gap: 40px; + + position: relative; + top: 80px; + left: 30px; + + width: 96%; + + @media (min-width: 576px) { + flex-direction: row; + justify-content: start; + + left: 40px; + } + + @media (min-width: 1280px) { + gap: 150px; + + left: 70px; + } +`; + +export const Title = styled.input` + color: gray; + + font-size: 2rem; +`; + +export const Content = styled.textarea` + height: 300px; + color: gray; + + font-size: 1.4rem; + font-family: 'Raleway', sans-serif; + + @media (min-width: 576px) { + height: 670px; + } +`; + +export const RightSide = styled.div` + display: flex; + flex-direction: column; + justfiy-content: start; + gap: 30px; + + width: 90%; +`; + +export const LeftSide = styled.div` + display: flex; + flex-direction: column; + justfiy-content: start; + gap: 20px; + + width: 90%; + + @media (min-width: 576px) { + margin-top: 40px; + } +`; + +export const OptionListWrapper = styled.div` + width: 100%; + max-width: 320px; + height: 540px; + + overflow-x: hidden; + overflow-y: scroll; + + @media (min-width: 576px) { + max-width: 500px; + } +`; + +export const Deadline = styled.p` + font-size: 1.5rem; + font-weight: bold; + text-align: center; +`; + +export const Description = styled.div` + color: gray; + + font-size: 1.2rem; +`; + +export const ButtonWrapper = styled.div` + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; + + width: 90%; + height: 90px; + margin-bottom: 30px; +`; + +export const ModalHeader = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + + width: 100%; + border-bottom: 1px solid #f6f6f6; + padding: 10px; + + font-size: 1.5rem; + font-weight: bold; +`; + +export const CloseButton = styled.button` + width: 25px; + height: 20px; + + background: white; + + font-size: 1.6rem; + + cursor: pointer; +`; +export const ModalBody = styled.div` + display: flex; + flex-direction: column; + justify-content: start; + align-items: center; + gap: 10px; + + padding: 10px 0; + + font-size: 1.4rem; +`; + +export const ResetButtonWrapper = styled.div` + display: flex; + + justify-content: center; + align-items: center; + + width: 50%; + height: 40px; +`; diff --git a/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/OptionUploadImageButton/OptionUploadImageButton.stories.tsx b/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/OptionUploadImageButton/OptionUploadImageButton.stories.tsx index 22d04d05d..d3329025f 100644 --- a/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/OptionUploadImageButton/OptionUploadImageButton.stories.tsx +++ b/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/OptionUploadImageButton/OptionUploadImageButton.stories.tsx @@ -10,5 +10,5 @@ export default meta; type Story = StoryObj; export const Default: Story = { - render: () => , + render: () => , }; diff --git a/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/OptionUploadImageButton/index.tsx b/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/OptionUploadImageButton/index.tsx index 8e276b236..82ca96808 100644 --- a/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/OptionUploadImageButton/index.tsx +++ b/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/OptionUploadImageButton/index.tsx @@ -6,16 +6,18 @@ import * as S from './style'; interface OptionUploadImageButtonProps extends React.InputHTMLAttributes { optionId: number; + isImageVisible: boolean; } export default function OptionUploadImageButton({ optionId, + isImageVisible, ...rest }: OptionUploadImageButtonProps) { const id = optionId.toString(); return ( - + diff --git a/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/OptionUploadImageButton/style.ts b/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/OptionUploadImageButton/style.ts index a8083d4aa..8509c65fd 100644 --- a/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/OptionUploadImageButton/style.ts +++ b/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/OptionUploadImageButton/style.ts @@ -2,10 +2,11 @@ import { styled } from 'styled-components'; import { ButtonCssText, IconImage } from '../style'; -export const Container = styled.div` +export const Container = styled.div<{ $isVisible: boolean }>` width: 24px; height: 24px; border-radius: 50%; + visibility: ${props => props.$isVisible && 'hidden'}; `; export const Label = styled.label` diff --git a/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/index.tsx b/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/index.tsx index 2e26a1ea3..d19bdf3d0 100644 --- a/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/index.tsx +++ b/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/index.tsx @@ -1,4 +1,6 @@ -import React, { ChangeEvent } from 'react'; +import React from 'react'; + +import { useText } from '@hooks/useText'; import OptionCancelButton from './OptionCancelButton'; import OptionUploadImageButton from './OptionUploadImageButton'; @@ -23,20 +25,9 @@ export default function WritingVoteOption({ handleDeleteOptionClick, handleRemoveImageClick, handleUploadImage, - imageUrl, + imageUrl = '', }: WritingVoteOptionProps) { - const handleTextChange = (event: ChangeEvent) => { - const { value } = event.target; - const standard = value.length; - - if (standard === MAX_WRITING_LENGTH) { - event.target.setCustomValidity(`선택지 내용은 ${MAX_WRITING_LENGTH}자까지 입력 가능합니다.`); - event.target.reportValidity(); - return; - } - - event.target.setCustomValidity(''); - }; + const { handleTextChange } = useText(''); return ( @@ -48,14 +39,20 @@ export default function WritingVoteOption({ ) => + handleTextChange(e, MAX_WRITING_LENGTH) + } placeholder="내용을 입력해주세요." maxLength={MAX_WRITING_LENGTH} /> - {!imageUrl && ( - - )} + + 0} + optionId={optionId} + onChange={handleUploadImage} + /> {imageUrl && ( diff --git a/frontend/src/components/optionList/WritingVoteOptionList/index.tsx b/frontend/src/components/optionList/WritingVoteOptionList/index.tsx index 233c80147..5582dc759 100644 --- a/frontend/src/components/optionList/WritingVoteOptionList/index.tsx +++ b/frontend/src/components/optionList/WritingVoteOptionList/index.tsx @@ -38,7 +38,7 @@ export default function WritingVoteOptionList({ initialOptionList }: WritingVote ))} {optionList.length < MAXIMUM_COUNT && ( - + )} diff --git a/frontend/src/constants/queryKey.ts b/frontend/src/constants/queryKey.ts new file mode 100644 index 000000000..51afe5aed --- /dev/null +++ b/frontend/src/constants/queryKey.ts @@ -0,0 +1,3 @@ +export const QUERY_KEY = { + POSTS: 'posts', +}; diff --git a/frontend/src/hooks/query/post/useCreatePost.ts b/frontend/src/hooks/query/post/useCreatePost.ts new file mode 100644 index 000000000..da37cda42 --- /dev/null +++ b/frontend/src/hooks/query/post/useCreatePost.ts @@ -0,0 +1,18 @@ +import { QUERY_KEY } from '@constants/queryKey'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { createPost } from '@api/jero/post'; + +export const useCreatePost = () => { + const queryClient = useQueryClient(); + const { mutate, isLoading, isError, error } = useMutation((post: FormData) => createPost(post), { + onSuccess: () => { + queryClient.invalidateQueries([QUERY_KEY.POSTS]); + }, + onError: error => { + window.console.log('createPost error', error); + }, + }); + + return { mutate, isLoading, isError, error }; +}; diff --git a/frontend/src/hooks/query/post/useEditPost.ts b/frontend/src/hooks/query/post/useEditPost.ts new file mode 100644 index 000000000..9fdedf5dc --- /dev/null +++ b/frontend/src/hooks/query/post/useEditPost.ts @@ -0,0 +1,21 @@ +import { QUERY_KEY } from '@constants/queryKey'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { editPost } from '@api/jero/post'; + +export const useEditPost = (postId: number) => { + const queryClient = useQueryClient(); + const { mutate, isLoading, isError, error } = useMutation( + (updatedPost: FormData) => editPost(postId, updatedPost), + { + onSuccess: () => { + queryClient.invalidateQueries([QUERY_KEY.POSTS, postId]); + }, + onError: error => { + window.console.log('editPost error', error); + }, + } + ); + + return { mutate, isLoading, isError, error }; +}; diff --git a/frontend/src/hooks/useText.ts b/frontend/src/hooks/useText.ts new file mode 100644 index 000000000..c5f17aa82 --- /dev/null +++ b/frontend/src/hooks/useText.ts @@ -0,0 +1,23 @@ +import React, { useState } from 'react'; + +export const useText = (originalText: string) => { + const [text, setText] = useState(originalText); + const handleTextChange = ( + event: React.ChangeEvent, + limit: number + ) => { + const { value } = event.target; + const standard = value.length; + + if (standard === limit) { + event.target.setCustomValidity(`선택지 내용은 ${limit}자까지 입력 가능합니다.`); + event.target.reportValidity(); + return; + } + + setText(value); + event.target.setCustomValidity(''); + }; + + return { text, handleTextChange }; +}; diff --git a/frontend/src/hooks/useToggle.tsx b/frontend/src/hooks/useToggle.tsx new file mode 100644 index 000000000..5e5fb167b --- /dev/null +++ b/frontend/src/hooks/useToggle.tsx @@ -0,0 +1,15 @@ +import { useState } from 'react'; + +export const useToggle = () => { + const [isOpen, setIsOpen] = useState(false); + + const openComponent = () => { + setIsOpen(true); + }; + + const closeComponent = () => { + setIsOpen(false); + }; + + return { isOpen, openComponent, closeComponent }; +}; diff --git a/frontend/src/mocks/handlers.ts b/frontend/src/mocks/handlers.ts index 42b745f7d..c3a6caf5a 100644 --- a/frontend/src/mocks/handlers.ts +++ b/frontend/src/mocks/handlers.ts @@ -1,5 +1,6 @@ import { example } from './example/get'; -import { getVoteDetailTest } from './sua/getVoteDetail'; +import { createEditPostTest } from './jero/post'; import { votePostTest } from './sua/vote'; +import { getVoteDetailTest } from './sua/getVoteDetail'; -export const handlers = [...example, ...votePostTest, ...getVoteDetailTest]; +export const handlers = [...example, ...votePostTest, ...getVoteDetailTest, ...createEditPostTest]; diff --git a/frontend/src/mocks/jero/post.ts b/frontend/src/mocks/jero/post.ts new file mode 100644 index 000000000..d037bac3a --- /dev/null +++ b/frontend/src/mocks/jero/post.ts @@ -0,0 +1,25 @@ +import { rest } from 'msw'; + +export const createEditPostTest = [ + //게시글 작성 + rest.post('/posts', (req, res, ctx) => { + window.console.log('게시글 작성 완료', req.body); + + return res( + ctx.delay(1000), + ctx.status(201), + ctx.json({ message: '게시글이 성공적으로 생성되었습니다!!' }) + ); + }), + + //게시글 수정 + rest.put('/posts/:postId', (req, res, ctx) => { + window.console.log('게시글 수정 완료', req.body); + + return res( + ctx.delay(1000), + ctx.status(200), + ctx.json({ message: '게시글이 성공적으로 수정되었습니다!!' }) + ); + }), +]; diff --git a/frontend/src/pages/post/CreatePost/index.tsx b/frontend/src/pages/post/CreatePost/index.tsx new file mode 100644 index 000000000..3f4c159ab --- /dev/null +++ b/frontend/src/pages/post/CreatePost/index.tsx @@ -0,0 +1,13 @@ +import { useCreatePost } from '@hooks/query/post/useCreatePost'; + +import PostForm from '@components/PostForm'; + +export default function CreatePost() { + const { mutate, isError, error } = useCreatePost(); + + return ( + <> + + + ); +} diff --git a/frontend/src/pages/post/EditPost/index.tsx b/frontend/src/pages/post/EditPost/index.tsx new file mode 100644 index 000000000..42ae72a67 --- /dev/null +++ b/frontend/src/pages/post/EditPost/index.tsx @@ -0,0 +1,62 @@ +import { useParams } from 'react-router-dom'; + +import { PostInfo } from '@type/post'; + +import { useEditPost } from '@hooks/query/post/useEditPost'; + +import PostForm from '@components/PostForm'; + +export default function EditPost() { + const { postId } = useParams(); + + // const { data } = usePostDetailQuery({ postId}); + const { mutate, isError, error } = useEditPost(Number(postId)); + + const MOCK_DATA: PostInfo = { + postId: 1, + title: '당신의 최애 동물에 투표하세요!', + writer: { id: 15216, nickname: 'jero' }, + content: + '한자로 견(犬)·구(狗) 등으로 표기한다. 포유류 중 가장 오래된 가축으로 거의 전세계에서 사육되며 약 400여 품종이 있다. 개는 이리·자칼(jackal) 등이 조상이라고 하는데, 이는 개와 교배하여 계대(繼代) 번식의 가능성이 있는 새끼를 낳을 수 있다는 것을 뜻한다. 즉 개에 이들의 혈액이 혼혈될 가능성이 있다는 것이다. 그러나 두개골이나 치아의 구조를 보면 개는 혼합된 것이 아니며, 또 그들 중의 어느 것에서 생긴 것이라고도 여겨지지 않는다. 아마도 개는 오스트레일리아에 야생하는 딩고(dingo)나 남아시아에 반야생상태로 서식하는 개와 흡사한, 절멸된 야생종에서 생긴 것으로 추측된다.', + category: [ + { id: 13215, name: '음식' }, + { id: 13217, name: '게임' }, + { id: 13219, name: '연예' }, + ], + startTime: '2023-07-18 12:40', + endTime: '2023-08-15 12:40', + voteInfo: { + selectedOptionId: 1, + allPeopleCount: 0, + options: [ + { + id: Math.floor(Math.random() * 100000), + text: '햄스터가 세상을 구한다.', + imageUrl: '', + peopleCount: 0, + percent: 20, + }, + { + id: Math.floor(Math.random() * 100000), + text: '강아지가 세상을 구한다.', + imageUrl: '', + peopleCount: 0, + percent: 10, + }, + { + id: Math.floor(Math.random() * 100000), + text: '고양이가 세상을 구한다.', + imageUrl: 'https://source.unsplash.com/random', + peopleCount: 0, + percent: 10, + }, + ], + }, + }; + + return ( + <> + + + ); +} diff --git a/frontend/src/pages/post/WritePost.tsx b/frontend/src/pages/post/WritePost.tsx deleted file mode 100644 index 5fa8ab027..000000000 --- a/frontend/src/pages/post/WritePost.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import React from 'react'; - -export default function WritePost() { - return
WritePost 글 작성/수정 페이지
; -} diff --git a/frontend/src/routes/router.tsx b/frontend/src/routes/router.tsx index 2054149ef..309e1f6c4 100644 --- a/frontend/src/routes/router.tsx +++ b/frontend/src/routes/router.tsx @@ -1,22 +1,27 @@ import { createBrowserRouter } from 'react-router-dom'; +import CreatePost from '@pages/post/CreatePost'; +import EditPost from '@pages/post/EditPost'; import PostDetail from '@pages/post/PostDetail'; import PostList from '@pages/post/PostList'; -import WritePost from '@pages/post/WritePost'; const router = createBrowserRouter([ { path: '/', - element: , children: [ + { path: '', element: }, { path: 'posts/write', - element: , + element: , }, { path: 'posts/:postId', element: , }, + { + path: 'posts/write/:postId', + element: , + }, ], }, ]); diff --git a/frontend/src/utils/fetch.ts b/frontend/src/utils/fetch.ts index d6f05ff8f..8859740dd 100644 --- a/frontend/src/utils/fetch.ts +++ b/frontend/src/utils/fetch.ts @@ -3,6 +3,11 @@ const headers = { Authorization: `Bearer `, }; +const multiHeaders = { + 'Content-Type': 'multipart/form-data', + Authorization: `Bearer `, +}; + export const getFetch = async (url: string): Promise => { const response = await fetch(url, { method: 'GET', @@ -75,3 +80,35 @@ export const deleteFetch = async (url: string) => { throw new Error(data.message); } }; + +export const multiPostFetch = async (url: string, body: FormData) => { + const response = await fetch(url, { + method: 'POST', + body, + headers: multiHeaders, + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message); + } + + return data; +}; + +export const multiPutFetch = async (url: string, body: FormData) => { + const response = await fetch(url, { + method: 'PUT', + body, + headers: multiHeaders, + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message); + } + + return data; +}; diff --git a/frontend/src/utils/post/formatTime.ts b/frontend/src/utils/post/formatTime.ts new file mode 100644 index 000000000..8353817c7 --- /dev/null +++ b/frontend/src/utils/post/formatTime.ts @@ -0,0 +1,32 @@ +interface Time { + day: number; + hour: number; + minute: number; +} + +export function addTimeToDate(addTime: Time, baseTime: Date) { + const { day, hour, minute } = addTime; + if (day === 0 && hour === 0 && minute === 0) return; + + const newTime = new Date(baseTime); + + newTime.setDate(baseTime.getDate() + day); + newTime.setHours(baseTime.getHours() + hour); + newTime.setMinutes(baseTime.getMinutes() + minute); + + const newYear = newTime.getFullYear(); + const newDay = String(newTime.getDate()).padStart(2, '0'); + const newMonth = String(newTime.getMonth() + 1).padStart(2, '0'); + const newHour = String(newTime.getHours()).padStart(2, '0'); + const newMinute = String(newTime.getMinutes()).padStart(2, '0'); + + return `${newYear}-${newMonth}-${newDay} ${newHour}:${newMinute}`; +} + +export function formatTimeWithOption(option: string) { + if (option === '10분') return { day: 0, hour: 0, minute: 10 }; + else if (option === '30분') return { day: 0, hour: 0, minute: 30 }; + else if (option === '1시간') return { day: 0, hour: 1, minute: 0 }; + else if (option === '6시간') return { day: 0, hour: 6, minute: 0 }; + else return { day: 1, hour: 0, minute: 0 }; +} From 411d0a001188bbefddef55422e549587b2fbae00 Mon Sep 17 00:00:00 2001 From: lookh <103165859+aiaiaiai1@users.noreply.github.com> Date: Thu, 20 Jul 2023 10:54:34 +0900 Subject: [PATCH 4/4] =?UTF-8?q?(=ED=9A=8C=EC=9B=90)=20=EC=84=A0=ED=98=B8?= =?UTF-8?q?=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20(#79)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: (#67) (회원) 선호 카테고리 삭제 API 기능 구현 * feat: (#67) Swagger 어노테이션 추가 * test: (#67) Controller 단위테스트, Service 통합테스트 추가 - 이전에 누락된 테스트 코드까지 추가함 * style: (#67) final 키워드 추가 * refactor: (#68) CategoryResponse 파라미터 값 수정 * feat: (#68) (회원) 카테고리 목록 전체 조회 API 추가 * teat: (#68) (회원) 레파지토리 테스트 추가 * teat: (#67) 선호하는 카테고리에 없는 카테고리를 삭제하는 경우 예외 테스트 추가 * refactor: (#67) 개행 및 스태틱 임포트 리펙터링 * feat: (#67) Swagger 어노테이션 에러 응답 설명 추가 * refactor: (#67) url 오타 수정 * refactor: (#67) 개행 및 컨벤션 수정 --- .../contorller/CategoryController.java | 28 ++- .../dto/response/CategoryResponse.java | 9 +- .../category/service/CategoryService.java | 35 +++- .../repository/MemberCategoryRepository.java | 4 + .../domain/vote/service/VoteService.java | 19 +- .../contorller/CategoryControllerTest.java | 80 +++++++++ .../category/service/CategoryServiceTest.java | 166 ++++++++++++++++++ .../MemberCategoryRepositoryTest.java | 50 ++++++ 8 files changed, 374 insertions(+), 17 deletions(-) create mode 100644 backend/src/test/java/com/votogether/domain/category/contorller/CategoryControllerTest.java create mode 100644 backend/src/test/java/com/votogether/domain/category/service/CategoryServiceTest.java diff --git a/backend/src/main/java/com/votogether/domain/category/contorller/CategoryController.java b/backend/src/main/java/com/votogether/domain/category/contorller/CategoryController.java index e7267cdd8..3e21fcb3d 100644 --- a/backend/src/main/java/com/votogether/domain/category/contorller/CategoryController.java +++ b/backend/src/main/java/com/votogether/domain/category/contorller/CategoryController.java @@ -5,11 +5,13 @@ import com.votogether.domain.member.entity.Member; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -28,16 +30,38 @@ public class CategoryController { @ApiResponse(responseCode = "200", description = "조회 성공") @GetMapping("/guest") public ResponseEntity> getAllCategories() { - List categories = categoryService.getAllCategories(); + final List categories = categoryService.getAllCategories(); return ResponseEntity.status(HttpStatus.OK).body(categories); } @Operation(summary = "선호 카테고리 추가하기", description = "선호하는 카테고리를 선호 카테고리 목록에 추가한다.") - @ApiResponse(responseCode = "201", description = "추가 성공") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "추가 성공"), + @ApiResponse(responseCode = "400", description = "해당 카테고리가 추가가 되어 있어 중복 추가 실패"), + @ApiResponse(responseCode = "404", description = "해당 카테고리가 존재하지 않아 추가 실패"), + }) @PostMapping("/{categoryId}/like") public ResponseEntity addFavoriteCategory(final Member member, @PathVariable final Long categoryId) { categoryService.addFavoriteCategory(member, categoryId); return ResponseEntity.status(HttpStatus.CREATED).build(); } + @Operation(summary = "선호 카테고리 삭제하기", description = "선호하는 카테고리를 선호 카테고리 목록에서 삭제한다.") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "삭제 성공"), + @ApiResponse(responseCode = "400", description = "선호하는 카테고리가 아니여서 삭제 실패"), + @ApiResponse(responseCode = "404", description = "해당 카테고리가 존재하지 않아 삭제 실패") + }) + @DeleteMapping("/{categoryId}/like") + public ResponseEntity removeFavoriteCategory(final Member member, @PathVariable final Long categoryId) { + categoryService.removeFavoriteCategory(member, categoryId); + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } + + @GetMapping + public ResponseEntity getAllCategories(final Member member) { + categoryService.getAllCategories(member); + return ResponseEntity.status(HttpStatus.OK).build(); + } + } diff --git a/backend/src/main/java/com/votogether/domain/category/dto/response/CategoryResponse.java b/backend/src/main/java/com/votogether/domain/category/dto/response/CategoryResponse.java index 6ad58c3b3..7a6181e82 100644 --- a/backend/src/main/java/com/votogether/domain/category/dto/response/CategoryResponse.java +++ b/backend/src/main/java/com/votogether/domain/category/dto/response/CategoryResponse.java @@ -1,6 +1,7 @@ package com.votogether.domain.category.dto.response; import com.votogether.domain.category.entity.Category; +import java.util.List; public record CategoryResponse( Long id, @@ -8,8 +9,12 @@ public record CategoryResponse( boolean isFavorite ) { - public CategoryResponse(final Category category) { - this(category.getId(), category.getName(), false); + public CategoryResponse(final Category category, final boolean isFavorite) { + this(category.getId(), category.getName(), isFavorite); + } + + public CategoryResponse(final Category category, final List favoriteCategories) { + this(category.getId(), category.getName(), favoriteCategories.contains(category)); } } diff --git a/backend/src/main/java/com/votogether/domain/category/service/CategoryService.java b/backend/src/main/java/com/votogether/domain/category/service/CategoryService.java index 0a63b68f9..017f13bc3 100644 --- a/backend/src/main/java/com/votogether/domain/category/service/CategoryService.java +++ b/backend/src/main/java/com/votogether/domain/category/service/CategoryService.java @@ -6,6 +6,7 @@ import com.votogether.domain.member.entity.Member; import com.votogether.domain.member.entity.MemberCategory; import com.votogether.domain.member.repository.MemberCategoryRepository; +import java.util.Comparator; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -20,22 +21,23 @@ public class CategoryService { @Transactional(readOnly = true) public List getAllCategories() { - List categories = categoryRepository.findAll(); + final List categories = categoryRepository.findAll(); return categories.stream() - .map(CategoryResponse::new) + .sorted(Comparator.comparing(Category::getName)) + .map(category -> new CategoryResponse(category, false)) .toList(); } @Transactional public void addFavoriteCategory(final Member member, final Long categoryId) { - Category category = categoryRepository.findById(categoryId) + final Category category = categoryRepository.findById(categoryId) .orElseThrow(() -> new IllegalArgumentException("해당 카테고리가 존재하지 않습니다.")); memberCategoryRepository.findByMemberAndCategory(member, category) .ifPresent(ignore -> new IllegalStateException("이미 선호 카테고리에 등록되어 있습니다.")); - MemberCategory memberCategory = MemberCategory.builder() + final MemberCategory memberCategory = MemberCategory.builder() .member(member) .category(category) .build(); @@ -43,4 +45,29 @@ public void addFavoriteCategory(final Member member, final Long categoryId) { memberCategoryRepository.save(memberCategory); } + @Transactional + public void removeFavoriteCategory(final Member member, final Long categoryId) { + final Category category = categoryRepository.findById(categoryId) + .orElseThrow(() -> new IllegalArgumentException("해당 카테고리가 존재하지 않습니다.")); + final MemberCategory memberCategory = memberCategoryRepository.findByMemberAndCategory(member, category) + .orElseThrow(() -> new IllegalArgumentException("해당 카테고리는 선호 카테고리가 아닙니다.")); + + memberCategoryRepository.delete(memberCategory); + } + + @Transactional(readOnly = true) + public List getAllCategories(final Member member) { + final List categories = categoryRepository.findAll(); + final List memberCategories = memberCategoryRepository.findByMember(member); + + final List favoriteCategories = memberCategories.stream() + .map(MemberCategory::getCategory) + .toList(); + + return categories.stream() + .sorted(Comparator.comparing(Category::getName)) + .map(category -> new CategoryResponse(category, favoriteCategories)) + .toList(); + } + } diff --git a/backend/src/main/java/com/votogether/domain/member/repository/MemberCategoryRepository.java b/backend/src/main/java/com/votogether/domain/member/repository/MemberCategoryRepository.java index 7cc979686..2ae486c3e 100644 --- a/backend/src/main/java/com/votogether/domain/member/repository/MemberCategoryRepository.java +++ b/backend/src/main/java/com/votogether/domain/member/repository/MemberCategoryRepository.java @@ -3,10 +3,14 @@ import com.votogether.domain.category.entity.Category; import com.votogether.domain.member.entity.Member; import com.votogether.domain.member.entity.MemberCategory; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface MemberCategoryRepository extends JpaRepository { + Optional findByMemberAndCategory(final Member member, final Category category); + List findByMember(final Member member); + } diff --git a/backend/src/main/java/com/votogether/domain/vote/service/VoteService.java b/backend/src/main/java/com/votogether/domain/vote/service/VoteService.java index 81871bc18..57436f43e 100644 --- a/backend/src/main/java/com/votogether/domain/vote/service/VoteService.java +++ b/backend/src/main/java/com/votogether/domain/vote/service/VoteService.java @@ -27,20 +27,21 @@ public void vote( final Long postId, final Long postOptionId ) { - Post post = postRepository.findById(postId) + final Post post = postRepository.findById(postId) .orElseThrow(() -> new IllegalArgumentException("해당 게시글이 존재하지 않습니다.")); validateAlreadyVoted(member, post); - PostOption postOption = postOptionRepository.findById(postOptionId) + final PostOption postOption = postOptionRepository.findById(postOptionId) .orElseThrow(() -> new IllegalArgumentException("해당 선택지가 존재하지 않습니다.")); - Vote vote = post.makeVote(member, postOption); + final Vote vote = post.makeVote(member, postOption); member.plusPoint(1); voteRepository.save(vote); } - private void validateAlreadyVoted(Member member, Post post) { + + private void validateAlreadyVoted(final Member member, final Post post) { final PostOptions postOptions = post.getPostOptions(); final List alreadyVoted = voteRepository.findByMemberAndPostOptionIn(member, postOptions.getPostOptions()); if (!alreadyVoted.isEmpty()) { @@ -54,20 +55,20 @@ public void changeVote( final Long originPostOptionId, final Long newPostOptionId ) { - Post post = postRepository.findById(postId) + final Post post = postRepository.findById(postId) .orElseThrow(() -> new IllegalArgumentException("해당 게시글이 존재하지 않습니다.")); - PostOption originPostOption = postOptionRepository.findById(originPostOptionId) + final PostOption originPostOption = postOptionRepository.findById(originPostOptionId) .orElseThrow(() -> new IllegalArgumentException("헤당 선택지가 존재하지 않습니다.")); - Vote originVote = voteRepository.findByMemberAndPostOption(member, originPostOption) + final Vote originVote = voteRepository.findByMemberAndPostOption(member, originPostOption) .orElseThrow(() -> new IllegalArgumentException("선택지에 해당되는 투표가 존재하지 않습니다.")); - PostOption newPostOption = postOptionRepository.findById(newPostOptionId) + final PostOption newPostOption = postOptionRepository.findById(newPostOptionId) .orElseThrow(() -> new IllegalArgumentException("헤당 선택지가 존재하지 않습니다.")); voteRepository.delete(originVote); - Vote vote = post.makeVote(member, newPostOption); + final Vote vote = post.makeVote(member, newPostOption); voteRepository.save(vote); } diff --git a/backend/src/test/java/com/votogether/domain/category/contorller/CategoryControllerTest.java b/backend/src/test/java/com/votogether/domain/category/contorller/CategoryControllerTest.java new file mode 100644 index 000000000..81d5226ee --- /dev/null +++ b/backend/src/test/java/com/votogether/domain/category/contorller/CategoryControllerTest.java @@ -0,0 +1,80 @@ +package com.votogether.domain.category.contorller; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.nullValue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doNothing; + +import com.votogether.domain.category.dto.response.CategoryResponse; +import com.votogether.domain.category.entity.Category; +import com.votogether.domain.category.service.CategoryService; +import io.restassured.module.mockmvc.RestAssuredMockMvc; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.HttpStatus; + +@WebMvcTest(CategoryController.class) +class CategoryControllerTest { + + @MockBean + CategoryService categoryService; + + @BeforeEach + void setUp() { + RestAssuredMockMvc.standaloneSetup(new CategoryController(categoryService)); + } + + @Test + @DisplayName("전체 카테고리 목록을 조회한다.") + void getAllCategories() { + // given + Category category = Category.builder() + .name("개발") + .build(); + given(categoryService.getAllCategories()).willReturn(List.of(new CategoryResponse(category, false))); + + // when + RestAssuredMockMvc. + given().log().all() + .when().get("/categories/guest") + .then().log().all() + .status(HttpStatus.OK) + .body("[0].id", nullValue()) + .body("[0].name", equalTo("개발")) + .body("[0].isFavorite", equalTo(false)); + } + + @Test + @DisplayName("선호하는 카테고리를 선호 카테고리 목록에 추가한다.") + void addFavoriteCategory() { + // given + doNothing().when(categoryService).addFavoriteCategory(any(), any()); + + // when & then + RestAssuredMockMvc. + given().log().all() + .when().post("/categories/{categoryId}/like", 1) + .then().log().all() + .status(HttpStatus.CREATED); + } + + @Test + @DisplayName("선호 카테고리를 삭제한다.") + void removeFavoriteCategory() { + // given + doNothing().when(categoryService).removeFavoriteCategory(any(), any()); + + // when & then + RestAssuredMockMvc. + given().log().all() + .when().delete("/categories/{categoryId}/like", 1) + .then().log().all() + .status(HttpStatus.NO_CONTENT); + } + +} diff --git a/backend/src/test/java/com/votogether/domain/category/service/CategoryServiceTest.java b/backend/src/test/java/com/votogether/domain/category/service/CategoryServiceTest.java new file mode 100644 index 000000000..7deaf4c0b --- /dev/null +++ b/backend/src/test/java/com/votogether/domain/category/service/CategoryServiceTest.java @@ -0,0 +1,166 @@ +package com.votogether.domain.category.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.votogether.domain.category.dto.response.CategoryResponse; +import com.votogether.domain.category.entity.Category; +import com.votogether.domain.category.repository.CategoryRepository; +import com.votogether.domain.member.entity.Gender; +import com.votogether.domain.member.entity.Member; +import com.votogether.domain.member.entity.MemberCategory; +import com.votogether.domain.member.entity.SocialType; +import com.votogether.domain.member.repository.MemberCategoryRepository; +import com.votogether.domain.member.repository.MemberRepository; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +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 org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@Transactional +class CategoryServiceTest { + + @Autowired + CategoryService categoryService; + + @Autowired + CategoryRepository categoryRepository; + + @Autowired + MemberCategoryRepository memberCategoryRepository; + + @Autowired + MemberRepository memberRepository; + + @Test + @DisplayName("모든 카테고리를 가져온다.") + void getAllCategories() { + // given + Category category = Category.builder() + .name("개발") + .build(); + + categoryRepository.save(category); + + // when + List categories = categoryService.getAllCategories(); + + // then + assertAll( + () -> assertThat(categories.get(0).id()).isNotNull(), + () -> assertThat(categories.get(0).name()).isEqualTo("개발"), + () -> assertThat(categories.get(0).isFavorite()).isFalse() + ); + } + + @Test + @DisplayName("선호하는 카테고리를 선호 카테고리 목록에 추가한다.") + void addFavoriteCategory() { + // given + Category category = Category.builder() + .name("개발") + .build(); + + Member member = Member.builder() + .gender(Gender.MALE) + .point(0) + .socialType(SocialType.GOOGLE) + .nickname("user1") + .socialId("kakao@gmail.com") + .birthDate(LocalDateTime.of(1995, 7, 12, 0, 0)) + .build(); + + categoryRepository.save(category); + memberRepository.save(member); + + Long categoryId = category.getId(); + + // when + categoryService.addFavoriteCategory(member, categoryId); + + // then + MemberCategory memberCategory = memberCategoryRepository.findByMemberAndCategory(member, category).get(); + + assertAll( + () -> assertThat(memberCategory.getMember()).isSameAs(member), + () -> assertThat(memberCategory.getCategory()).isSameAs(category) + ); + } + + @Nested + @DisplayName("카테고리 삭제") + class Deleting { + + @Test + @DisplayName("선호하는 카테고리를 선호 카테고리 목록에 삭제한다.") + void removeFavoriteCategory() { + // given + Category category = Category.builder() + .name("개발") + .build(); + + Member member = Member.builder() + .gender(Gender.MALE) + .point(0) + .socialType(SocialType.GOOGLE) + .nickname("user1") + .socialId("kakao@gmail.com") + .birthDate(LocalDateTime.of(1995, 7, 12, 0, 0)) + .build(); + + MemberCategory memberCategory = MemberCategory.builder() + .member(member) + .category(category) + .build(); + + categoryRepository.save(category); + memberRepository.save(member); + memberCategoryRepository.save(memberCategory); + + Long categoryId = category.getId(); + + // when + categoryService.removeFavoriteCategory(member, categoryId); + + // then + Optional foundMemberCategory = + memberCategoryRepository.findByMemberAndCategory(member, category); + assertThat(foundMemberCategory).isEmpty(); + } + + @Test + @DisplayName("선호하는 카테고리에 없는 카테고리를 삭제하는 경우 예외가 발생한다.") + void removeFavoriteCategoryException() { + // given + Category category = Category.builder() + .name("개발") + .build(); + + Member member = Member.builder() + .gender(Gender.MALE) + .point(0) + .socialType(SocialType.GOOGLE) + .nickname("user1") + .socialId("kakao@gmail.com") + .birthDate(LocalDateTime.of(1995, 7, 12, 0, 0)) + .build(); + + categoryRepository.save(category); + memberRepository.save(member); + + // when, then + assertThatThrownBy(() -> categoryService.removeFavoriteCategory(member, category.getId())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("해당 카테고리는 선호 카테고리가 아닙니다."); + } + + } + +} diff --git a/backend/src/test/java/com/votogether/domain/member/repository/MemberCategoryRepositoryTest.java b/backend/src/test/java/com/votogether/domain/member/repository/MemberCategoryRepositoryTest.java index c46cc1a97..db1eecbc7 100644 --- a/backend/src/test/java/com/votogether/domain/member/repository/MemberCategoryRepositoryTest.java +++ b/backend/src/test/java/com/votogether/domain/member/repository/MemberCategoryRepositoryTest.java @@ -1,6 +1,7 @@ package com.votogether.domain.member.repository; import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; import com.votogether.domain.RepositoryTest; import com.votogether.domain.category.entity.Category; @@ -10,6 +11,7 @@ import com.votogether.domain.member.entity.MemberCategory; import com.votogether.domain.member.entity.SocialType; import java.time.LocalDateTime; +import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -94,4 +96,52 @@ void findByMemberAndCategory() { assertThat(findMemberCategory).isSameAs(memberCategory); } + @Test + @DisplayName("멤버를 통해 멤버 카테고리 목록을 조회힌다.") + void findByMember() { + // given + Category category = Category.builder() + .name("개발") + .build(); + + Category category1 = Category.builder() + .name("음식") + .build(); + + Member member = Member.builder() + .gender(Gender.MALE) + .point(0) + .socialType(SocialType.GOOGLE) + .nickname("user1") + .socialId("kakao@gmail.com") + .birthDate( + LocalDateTime.of(1995, 07, 12, 00, 00)) + .build(); + + MemberCategory memberCategory = MemberCategory.builder() + .member(member) + .category(category) + .build(); + + MemberCategory memberCategory1 = MemberCategory.builder() + .member(member) + .category(category1) + .build(); + + categoryRepository.save(category); + categoryRepository.save(category1); + memberRepository.save(member); + memberCategoryRepository.save(memberCategory); + memberCategoryRepository.save(memberCategory1); + + // when + List memberCategories = memberCategoryRepository.findByMember(member); + + // then + assertAll( + () -> assertThat(memberCategories).hasSize(2), + () -> assertThat(memberCategories).contains(memberCategory, memberCategory1) + ); + } + }