From b7a90508db4626ae2eabedbc0f48b9bddafc36d4 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: Mon, 17 Jul 2023 17:20:56 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20(#59)=20=EC=8A=A4=EC=BC=88=EB=A0=88?= =?UTF-8?q?=ED=86=A4=20UI=20=EA=B5=AC=ED=98=84=20(#60)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/Skeleton/Skeleton.stories.tsx | 14 +++++ .../src/components/common/Skeleton/index.tsx | 13 +++++ .../src/components/common/Skeleton/style.ts | 58 +++++++++++++++++++ 3 files changed, 85 insertions(+) create mode 100644 frontend/src/components/common/Skeleton/Skeleton.stories.tsx create mode 100644 frontend/src/components/common/Skeleton/index.tsx create mode 100644 frontend/src/components/common/Skeleton/style.ts diff --git a/frontend/src/components/common/Skeleton/Skeleton.stories.tsx b/frontend/src/components/common/Skeleton/Skeleton.stories.tsx new file mode 100644 index 000000000..dfe57858f --- /dev/null +++ b/frontend/src/components/common/Skeleton/Skeleton.stories.tsx @@ -0,0 +1,14 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import Skeleton from '.'; + +const meta: Meta = { + component: Skeleton, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => , +}; diff --git a/frontend/src/components/common/Skeleton/index.tsx b/frontend/src/components/common/Skeleton/index.tsx new file mode 100644 index 000000000..c6831f520 --- /dev/null +++ b/frontend/src/components/common/Skeleton/index.tsx @@ -0,0 +1,13 @@ +import React from 'react'; + +import * as S from './style'; + +export default function Skeleton() { + return ( + + + + + + ); +} diff --git a/frontend/src/components/common/Skeleton/style.ts b/frontend/src/components/common/Skeleton/style.ts new file mode 100644 index 000000000..23d4950e1 --- /dev/null +++ b/frontend/src/components/common/Skeleton/style.ts @@ -0,0 +1,58 @@ +import { keyframes, styled } from 'styled-components'; + +import { theme } from '@styles/theme'; + +const skeletonGradient = keyframes` + 0% { + background-color: rgba(165, 165, 165, 0.1); + } + + 50% { + background-color: rgba(165, 165, 165, 0.3); + } + + 100% { + background-color: rgba(165, 165, 165, 0.1); + } +`; + +export const Container = styled.div` + display: flex; + flex-direction: column; + gap: 9px; + + @media (min-width: ${theme.breakpoint.sm}) { + gap: 12px; + } +`; + +const Box = styled.div` + border-radius: 4px; + + -webkit-animation: ${skeletonGradient} 1.8s infinite ease-in-out; + animation: ${skeletonGradient} 1.8s infinite ease-in-out; +`; + +export const FirstBox = styled(Box)` + height: 110px; + + @media (min-width: ${theme.breakpoint.sm}) { + height: 140px; + } +`; + +export const SecondBox = styled(Box)` + height: 20px; + + @media (min-width: ${theme.breakpoint.sm}) { + height: 30px; + } +`; + +export const ThirdBox = styled(Box)` + height: 10px; + + @media (min-width: ${theme.breakpoint.sm}) { + height: 15px; + } +`; From fd5b750811bf5ce8976cb6d87d954915ff7cabc6 Mon Sep 17 00:00:00 2001 From: chsua <113416448+chsua@users.noreply.github.com> Date: Tue, 18 Jul 2023 10:31:15 +0900 Subject: [PATCH 2/5] =?UTF-8?q?=ED=88=AC=ED=91=9C=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=ED=86=B5=EA=B3=84=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EC=A0=9C=EC=9E=91=5F#54=20(#56)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: (#54) 투표 통계 mockData 생성 * feat: (#54) 투표 통계 관련 type, interface 생성 - 나이대 type - 투표통계 총결과 interface * feat: (#54) 그래프 공통 스타일 생성 * feat: (#54) 막대 하나 그래프 구현 * test: (#54) 막대 하나 그래프 사이즈별 테스트 구현 * feat: (#54) 막대 두개 그래프 구현 * test: (#54) 막대 두개 그래프 사이즈별 테스트 구현 * design: (#54) 그래프 공통 스타일 수정 * feat: (#54) 라디오를 포함한 투표 통계 결과 그래프 컴포넌트 구현 * test: (#54) 라디오를 포함한 투표 통계 결과 그래프 컴포넌트 테스트 구현 * refactor: 라디오를 포함한 투표 통계결과 그래프 컴포넌트명/폴더명 변경 - VoteResult -> VoteStatistics - 수정이유: 투표결과 데이터 타입명과 중복 * style: (#54) styled component 파일 컨벤션에 맞춰 순서 수정 * refactor: (#54) 통계 컴포넌트 타입/인터베이스 오타 수정 * refactor: (#54) 그래프 스타일 상수화하여 코드 정리 * refactor: (#54) 공통된 그래프 프롭스 interface 리팩토링 * refactor: 투표 통계 나이대 속성 type 리팩토링 - 투표 나이대 상수화 - 투표 나이대 상수에서 나이대 type 추출 - VoteDetailResult를 value로 가지는 투표 나이대 객체 type 생성 - 기존 voteResult interface에 투표 나이대 객체 type 연결 * refactor: (#54) 몇몇 컴포넌트 반응형 웹 기준 상수화 적용 - 게시글, 선택지, 투표 통계 컴포넌트 * refactor: (#54) 몇몇 컴포넌트 반응형 웹 기준 상수화 재적용 - 게시글, 선택지, 투표 통계 컴포넌트 --- frontend/src/components/Example/style.ts | 6 +- .../components/VoteStatistics/GraphStyle.ts | 33 ++++++++++ .../OneLineGraph/OneLineGraph.stories.tsx | 24 +++++++ .../VoteStatistics/OneLineGraph/index.tsx | 28 +++++++++ .../VoteStatistics/OneLineGraph/style.ts | 34 ++++++++++ .../TwoLineGraph/TwoLineGraph.stories.tsx | 24 +++++++ .../VoteStatistics/TwoLineGraph/index.tsx | 41 ++++++++++++ .../VoteStatistics/TwoLineGraph/style.ts | 63 +++++++++++++++++++ .../VoteStatistics/VoteStatistics.stories.tsx | 24 +++++++ .../src/components/VoteStatistics/index.tsx | 53 ++++++++++++++++ .../src/components/VoteStatistics/mockData.ts | 15 +++++ .../src/components/VoteStatistics/style.ts | 26 ++++++++ .../src/components/VoteStatistics/type.ts | 31 +++++++++ frontend/src/components/common/Post/style.ts | 12 ++-- .../WritingVoteOption/style.ts | 8 ++- .../WrittenVoteOption/style.ts | 18 +++--- .../optionList/WrittenVoteOptionList/style.ts | 4 +- 17 files changed, 423 insertions(+), 21 deletions(-) create mode 100644 frontend/src/components/VoteStatistics/GraphStyle.ts create mode 100644 frontend/src/components/VoteStatistics/OneLineGraph/OneLineGraph.stories.tsx create mode 100644 frontend/src/components/VoteStatistics/OneLineGraph/index.tsx create mode 100644 frontend/src/components/VoteStatistics/OneLineGraph/style.ts create mode 100644 frontend/src/components/VoteStatistics/TwoLineGraph/TwoLineGraph.stories.tsx create mode 100644 frontend/src/components/VoteStatistics/TwoLineGraph/index.tsx create mode 100644 frontend/src/components/VoteStatistics/TwoLineGraph/style.ts create mode 100644 frontend/src/components/VoteStatistics/VoteStatistics.stories.tsx create mode 100644 frontend/src/components/VoteStatistics/index.tsx create mode 100644 frontend/src/components/VoteStatistics/mockData.ts create mode 100644 frontend/src/components/VoteStatistics/style.ts create mode 100644 frontend/src/components/VoteStatistics/type.ts diff --git a/frontend/src/components/Example/style.ts b/frontend/src/components/Example/style.ts index e1a68fe06..83b743789 100644 --- a/frontend/src/components/Example/style.ts +++ b/frontend/src/components/Example/style.ts @@ -1,10 +1,8 @@ import { styled } from 'styled-components'; -import { theme } from '@styles/theme'; - export const Button = styled.button` width: 80px; - color: ${theme.color.white}; - background: ${theme.color.primary}; + color: red; + background: black; font-size: 1rem; `; diff --git a/frontend/src/components/VoteStatistics/GraphStyle.ts b/frontend/src/components/VoteStatistics/GraphStyle.ts new file mode 100644 index 000000000..a06aa11ed --- /dev/null +++ b/frontend/src/components/VoteStatistics/GraphStyle.ts @@ -0,0 +1,33 @@ +import { styled } from 'styled-components'; + +import { Size } from '@components/common/AddButton/type'; + +import { theme } from '@styles/theme'; + +const size: { [key in Size]: { height: string; linePositionTop: string } } = { + sm: { height: '200px', linePositionTop: '165px' }, + md: { height: '230px', linePositionTop: '194px' }, + lg: { height: '260px', linePositionTop: '224px' }, +}; + +export const GraphContainer = styled.div<{ $size: Size }>` + display: flex; + + height: ${props => `${size[props.$size].height}`}; + + position: relative; + + font-size: 1.2rem; + + @media (min-width: ${theme.breakpoint.sm}) { + font-size: 1.4rem; + } +`; + +export const Line = styled.div<{ $size: Size }>` + width: 100%; + border-bottom: 2px solid black; + + position: absolute; + top: ${props => `${size[props.$size].linePositionTop}`}; +`; diff --git a/frontend/src/components/VoteStatistics/OneLineGraph/OneLineGraph.stories.tsx b/frontend/src/components/VoteStatistics/OneLineGraph/OneLineGraph.stories.tsx new file mode 100644 index 000000000..227f058d8 --- /dev/null +++ b/frontend/src/components/VoteStatistics/OneLineGraph/OneLineGraph.stories.tsx @@ -0,0 +1,24 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { mockVoteResult } from '../mockData'; + +import OneLineGraph from '.'; + +const meta: Meta = { + component: OneLineGraph, +}; + +export default meta; +type Story = StoryObj; + +export const SizeSm: Story = { + render: () => , +}; + +export const SizeMd: Story = { + render: () => , +}; + +export const SizeLg: Story = { + render: () => , +}; diff --git a/frontend/src/components/VoteStatistics/OneLineGraph/index.tsx b/frontend/src/components/VoteStatistics/OneLineGraph/index.tsx new file mode 100644 index 000000000..65fd6b98e --- /dev/null +++ b/frontend/src/components/VoteStatistics/OneLineGraph/index.tsx @@ -0,0 +1,28 @@ +import * as GS from '../GraphStyle'; +import { AGE_OPTION, GraphProps } from '../type'; + +import * as S from './style'; + +export default function OneLineGraph({ voteResult, size }: GraphProps) { + const maxVoteAmount = Math.max( + ...Object.values(voteResult.age).map(voteResult => voteResult.total) + ); + + return ( + + + {AGE_OPTION.map(option => { + const voteResultFilteredByAge = voteResult.age[option]; + const amount = Math.floor((voteResultFilteredByAge.total / maxVoteAmount) * 100); + + return ( + + {voteResultFilteredByAge.total} + + {voteResultFilteredByAge.name} + + ); + })} + + ); +} diff --git a/frontend/src/components/VoteStatistics/OneLineGraph/style.ts b/frontend/src/components/VoteStatistics/OneLineGraph/style.ts new file mode 100644 index 000000000..ef0edbbbf --- /dev/null +++ b/frontend/src/components/VoteStatistics/OneLineGraph/style.ts @@ -0,0 +1,34 @@ +import { styled } from 'styled-components'; + +import { Size } from '@components/common/AddButton/type'; + +import { theme } from '@styles/theme'; + +export const OptionContainer = styled.div<{ $size: Size }>` + display: flex; + flex-direction: column; + justify-content: flex-end; + align-items: center; + gap: 5px; + + width: ${props => (props.$size === 'sm' ? '30px' : props.$size === 'md' ? '40px' : '50px')}; + + & > :last-child { + height: 30px; + + text-align: center; + word-break: keep-all; + } + + @media (min-width: ${theme.breakpoint.sm}) { + width: ${props => (props.$size === 'sm' ? '40px' : props.$size === 'md' ? '50px' : '60px')}; + } +`; + +export const OptionLength = styled.div<{ $amount: number }>` + height: ${props => `${props.$amount * 0.8}%`}; + width: 40%; + border-radius: 5px 5px 0 0; + + background-color: #f27676; +`; diff --git a/frontend/src/components/VoteStatistics/TwoLineGraph/TwoLineGraph.stories.tsx b/frontend/src/components/VoteStatistics/TwoLineGraph/TwoLineGraph.stories.tsx new file mode 100644 index 000000000..0fefb3d8b --- /dev/null +++ b/frontend/src/components/VoteStatistics/TwoLineGraph/TwoLineGraph.stories.tsx @@ -0,0 +1,24 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { mockVoteResult } from '../mockData'; + +import TwoLineGraph from '.'; + +const meta: Meta = { + component: TwoLineGraph, +}; + +export default meta; +type Story = StoryObj; + +export const SizeSm: Story = { + render: () => , +}; + +export const SizeMd: Story = { + render: () => , +}; + +export const SizeLg: Story = { + render: () => , +}; diff --git a/frontend/src/components/VoteStatistics/TwoLineGraph/index.tsx b/frontend/src/components/VoteStatistics/TwoLineGraph/index.tsx new file mode 100644 index 000000000..00e2466cd --- /dev/null +++ b/frontend/src/components/VoteStatistics/TwoLineGraph/index.tsx @@ -0,0 +1,41 @@ +import * as GS from '../GraphStyle'; +import { AGE_OPTION, GraphProps } from '../type'; + +import * as S from './style'; + +export default function TwoLineGraph({ voteResult, size }: GraphProps) { + const maxVoteAmount = Math.max( + ...Object.values(voteResult.age).map(voteResult => Math.max(voteResult.female, voteResult.male)) + ); + + return ( + + + {AGE_OPTION.map(option => { + const voteResultFilteredByAge = voteResult.age[option]; + + return ( + + + + {voteResultFilteredByAge.female} + + + + {voteResultFilteredByAge.male} + + + + {voteResultFilteredByAge.name} + + ); + })} + + ); +} diff --git a/frontend/src/components/VoteStatistics/TwoLineGraph/style.ts b/frontend/src/components/VoteStatistics/TwoLineGraph/style.ts new file mode 100644 index 000000000..634d871d9 --- /dev/null +++ b/frontend/src/components/VoteStatistics/TwoLineGraph/style.ts @@ -0,0 +1,63 @@ +import { styled } from 'styled-components'; + +import { Size } from '@components/common/AddButton/type'; + +import { theme } from '@styles/theme'; + +export const OptionContainer = styled.div<{ $size: Size }>` + display: flex; + flex-direction: column; + justify-content: flex-end; + align-items: center; + gap: 5px; + + width: ${props => (props.$size === 'sm' ? '30px' : props.$size === 'md' ? '40px' : '50px')}; + + & > :last-child { + height: 30px; + + text-align: center; + word-break: keep-all; + } + + @media (min-width: ${theme.breakpoint.sm}) { + width: ${props => (props.$size === 'sm' ? '40px' : props.$size === 'md' ? '50px' : '60px')}; + } +`; + +export const DataWrapper = styled.div` + display: flex; + justify-content: center; + + height: 90%; + width: 50px; + + @media (min-width: ${theme.breakpoint.sm}) { + width: 60px; + } +`; + +export const OptionLengthWrapper = styled.div<{ $gender: 'female' | 'male' }>` + display: flex; + flex-direction: column; + justify-content: flex-end; + align-items: center; + gap: 5px; + + height: 100%; + width: 20%; + + & > :first-child { + position: relative; + left: ${props => props.$gender === 'male' && '3px'}; + right: ${props => props.$gender === 'female' && '3px'}; + } +`; + +export const OptionLength = styled.div<{ $amount: number; $gender: 'female' | 'male' }>` + height: ${props => `${props.$amount}% `}; + width: 100%; + border-radius: 5px 5px 0 0; + + background-color: ${props => (props.$gender === 'female' ? '#853DE1' : '#5AEAA5')}; +`; diff --git a/frontend/src/components/VoteStatistics/VoteStatistics.stories.tsx b/frontend/src/components/VoteStatistics/VoteStatistics.stories.tsx new file mode 100644 index 000000000..fb187667d --- /dev/null +++ b/frontend/src/components/VoteStatistics/VoteStatistics.stories.tsx @@ -0,0 +1,24 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { mockVoteResult } from './mockData'; + +import VoteStatistics from '.'; + +const meta: Meta = { + component: VoteStatistics, +}; + +export default meta; +type Story = StoryObj; + +export const SizeSm: Story = { + render: () => , +}; + +export const SizeMd: Story = { + render: () => , +}; + +export const SizeLg: Story = { + render: () => , +}; diff --git a/frontend/src/components/VoteStatistics/index.tsx b/frontend/src/components/VoteStatistics/index.tsx new file mode 100644 index 000000000..5931bc056 --- /dev/null +++ b/frontend/src/components/VoteStatistics/index.tsx @@ -0,0 +1,53 @@ +import { MouseEvent, useState } from 'react'; + +import OneLineGraph from './OneLineGraph'; +import * as S from './style'; +import TwoLineGraph from './TwoLineGraph'; +import { GraphProps } from './type'; + +interface RadioMode { + all: string; + gender: string; +} + +const radioMode: RadioMode = { + all: '전체보기', + gender: '성별보기', +}; + +type RadioCategory = keyof RadioMode; + +export default function VoteStatistics({ voteResult, size }: GraphProps) { + const [nowRadioMode, setNowRadioMode] = 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); + }; + + return ( + + + {radioModeKey.map(mode => { + return ( + + + {radioMode[mode]} + + ); + })} + + {nowRadioMode === 'all' && } + {nowRadioMode === 'gender' && } + + ); +} diff --git a/frontend/src/components/VoteStatistics/mockData.ts b/frontend/src/components/VoteStatistics/mockData.ts new file mode 100644 index 000000000..e1a819c67 --- /dev/null +++ b/frontend/src/components/VoteStatistics/mockData.ts @@ -0,0 +1,15 @@ +export const mockVoteResult = { + total: 100, + female: 30, + name: '총합', + male: 70, + age: { + underTeenager: { total: 10, female: 10, male: 0, name: '10대 미만' }, + teenager: { total: 20, female: 10, male: 10, name: '10대' }, + twenties: { total: 10, female: 2, male: 8, name: '20대' }, + thirties: { total: 20, female: 16, male: 4, name: '30대' }, + forties: { total: 40, female: 30, male: 10, name: '40대' }, + fifties: { total: 2, female: 1, male: 1, name: '50대' }, + aboveFifties: { total: 3, female: 2, male: 1, name: '60대 이상' }, + }, +}; diff --git a/frontend/src/components/VoteStatistics/style.ts b/frontend/src/components/VoteStatistics/style.ts new file mode 100644 index 000000000..87c9be40c --- /dev/null +++ b/frontend/src/components/VoteStatistics/style.ts @@ -0,0 +1,26 @@ +import { styled } from 'styled-components'; + +import { theme } from '@styles/theme'; + +export const Container = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 15px; + + font-size: 1.2rem; + + @media (min-width: ${theme.breakpoint.sm}) { + font-size: 1.4rem; + } +`; + +export const CategoryWrapper = styled.fieldset` + display: flex; + gap: 10px; +`; + +export const RadioLabel = styled.label` + display: flex; + gap: 5px; +`; diff --git a/frontend/src/components/VoteStatistics/type.ts b/frontend/src/components/VoteStatistics/type.ts new file mode 100644 index 000000000..693d42c24 --- /dev/null +++ b/frontend/src/components/VoteStatistics/type.ts @@ -0,0 +1,31 @@ +import { Size } from '@components/common/AddButton/type'; + +export interface GraphProps { + voteResult: VoteResult; + size: Size; +} + +interface VoteDetailResult { + name: string; + total: number; + female: number; + male: number; +} + +export const AGE_OPTION = [ + 'underTeenager', + 'teenager', + 'twenties', + 'thirties', + 'forties', + 'fifties', + 'aboveFifties', +] as const; + +export type AgeCategory = (typeof AGE_OPTION)[number]; + +export type VoteResultAge = Record; + +export interface VoteResult extends VoteDetailResult { + age: VoteResultAge; +} diff --git a/frontend/src/components/common/Post/style.ts b/frontend/src/components/common/Post/style.ts index 920803dba..d43d171b3 100644 --- a/frontend/src/components/common/Post/style.ts +++ b/frontend/src/components/common/Post/style.ts @@ -1,5 +1,7 @@ import { styled } from 'styled-components'; +import { theme } from '@styles/theme'; + export const Container = styled.li` display: flex; flex-direction: column; @@ -9,7 +11,7 @@ export const Container = styled.li` letter-spacing: 0.5px; line-height: 1.5; - @media (min-width: 576px) { + @media (min-width: ${theme.breakpoint.sm}) { font-size: 1.4rem; } `; @@ -17,7 +19,7 @@ export const Container = styled.li` export const Category = styled.span` font-size: 1.2rem; - @media (min-width: 576px) { + @media (min-width: ${theme.breakpoint.sm}) { font-size: 1.4rem; } `; @@ -34,7 +36,7 @@ export const Title = styled.p<{ $isPreview: boolean }>` -webkit-line-clamp: ${props => props.$isPreview && '2'}; -webkit-box-orient: vertical; - @media (min-width: 576px) { + @media (min-width: ${theme.breakpoint.sm}) { font-size: 2.2rem; } `; @@ -52,7 +54,7 @@ export const Wrapper = styled.div` margin-left: 10px; } - @media (min-width: 576px) { + @media (min-width: ${theme.breakpoint.sm}) { font-size: 1.4rem; } `; @@ -73,7 +75,7 @@ export const Content = styled.p<{ $isPreview: boolean }>` -webkit-line-clamp: ${props => props.$isPreview && '10'}; -webkit-box-orient: vertical; - @media (min-width: 576px) { + @media (min-width: ${theme.breakpoint.sm}) { font-size: 1.6rem; } `; diff --git a/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/style.ts b/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/style.ts index 5a061ebd6..cc35a73dd 100644 --- a/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/style.ts +++ b/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/style.ts @@ -1,5 +1,7 @@ import { styled } from 'styled-components'; +import { theme } from '@styles/theme'; + export const Container = styled.li` display: flex; gap: 10px; @@ -37,7 +39,7 @@ export const ContentTextArea = styled.textarea` resize: none; - @media (min-width: 960px) { + @media (min-width: ${theme.breakpoint.md}) { height: 120px; font-size: 1.6rem; @@ -83,7 +85,7 @@ background-color: #bebebe; cursor: pointer; -@media (min-width: 960px) { +@media (min-width: ${theme.breakpoint.md}) { width:28px; height:28px; } @@ -93,7 +95,7 @@ export const IconImage = styled.img` width: 14px; height: 14px; - @media (min-width: 960px) { + @media (min-width: ${theme.breakpoint.md}) { width: 16px; height: 16px; } diff --git a/frontend/src/components/optionList/WrittenVoteOptionList/WrittenVoteOption/style.ts b/frontend/src/components/optionList/WrittenVoteOptionList/WrittenVoteOption/style.ts index d26b33e24..94ad6fbe0 100644 --- a/frontend/src/components/optionList/WrittenVoteOptionList/WrittenVoteOption/style.ts +++ b/frontend/src/components/optionList/WrittenVoteOptionList/WrittenVoteOption/style.ts @@ -1,5 +1,7 @@ import { styled } from 'styled-components'; +import { theme } from '@styles/theme'; + export const Container = styled.li<{ $isSelected: boolean }>` display: flex; flex-direction: column; @@ -13,7 +15,7 @@ export const Container = styled.li<{ $isSelected: boolean }>` cursor: pointer; - @media (min-width: 960px) { + @media (min-width: ${theme.breakpoint.md}) { padding: 20px 30px; } `; @@ -26,7 +28,7 @@ export const Image = styled.img` aspect-ratio: 1/1; object-fit: cover; - @media (min-width: 960px) { + @media (min-width: ${theme.breakpoint.md}) { margin-bottom: 24px; } `; @@ -44,7 +46,7 @@ export const PreviewContent = styled.p` -webkit-line-clamp: 2; // 원하는 라인수 -webkit-box-orient: vertical; - @media (min-width: 960px) { + @media (min-width: ${theme.breakpoint.md}) { font-size: 1.6rem; } `; @@ -53,7 +55,7 @@ export const DetailContent = styled.p` font-size: 1.4rem; font-weight: 500; - @media (min-width: 960px) { + @media (min-width: ${theme.breakpoint.md}) { font-size: 1.6rem; } `; @@ -61,7 +63,7 @@ export const DetailContent = styled.p` export const ProgressContainer = styled.div` margin-top: 12px; - @media (min-width: 960px) { + @media (min-width: ${theme.breakpoint.md}) { margin-top: 18px; } `; @@ -72,7 +74,7 @@ export const TextContainer = styled.div` text-align: end; font-weight: 500; - @media (min-width: 960px) { + @media (min-width: ${theme.breakpoint.md}) { margin-top: 12px; font-size: 1.6rem; @@ -82,7 +84,7 @@ export const TextContainer = styled.div` export const PeopleText = styled.span` font-size: 1.4rem; - @media (min-width: 960px) { + @media (min-width: ${theme.breakpoint.md}) { font-size: 1.6rem; } `; @@ -94,7 +96,7 @@ export const PercentText = styled.span` opacity: 0.7; - @media (min-width: 960px) { + @media (min-width: ${theme.breakpoint.md}) { font-size: 1.4rem; } `; diff --git a/frontend/src/components/optionList/WrittenVoteOptionList/style.ts b/frontend/src/components/optionList/WrittenVoteOptionList/style.ts index 032f4616a..96e6bb269 100644 --- a/frontend/src/components/optionList/WrittenVoteOptionList/style.ts +++ b/frontend/src/components/optionList/WrittenVoteOptionList/style.ts @@ -1,5 +1,7 @@ import { styled } from 'styled-components'; +import { theme } from '@styles/theme'; + export const VoteOptionListContainer = styled.ul` display: flex; flex-direction: column; @@ -7,7 +9,7 @@ export const VoteOptionListContainer = styled.ul` width: 100%; - @media (min-width: 960px) { + @media (min-width: ${theme.breakpoint.md}) { gap: 18px; } `; From 9e863e861cbbf3be4f0cf8fb31c9f8cc8c2699df 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: Tue, 18 Jul 2023 10:40:35 +0900 Subject: [PATCH 3/5] =?UTF-8?q?=EC=82=AC=EC=9D=B4=EB=93=9C=EB=B0=94(Drawer?= =?UTF-8?q?)=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20UI=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#57)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: (#41) 카테고리 토글 컴포넌트 UI 구현 * feat: (#41) 유저 정보 창 UI r구현중 * feat: (#41) 회원 유저 프로필 창 UI 구현 * feat: (#41) 비회원 프로필 창 UI 구현 * feat: (#41) 카테고리 타입 선언 * feat: (#41) 유저 타입 선언 * refactor: (#41) 선언한 타입으로 기존의 코드 변경 * feat: (#41) 사이드에 있는 유저 대쉬보드 컴포넌트 UI 구현 * feat: (#41) 공용 Drawer 컴포넌트 UI 구현 Drawer 사용 방법에 대한 예제 코드를 스토리북에 작성 * refactor: (#41) drawer 동작에 필요한 코드를 useDrawer 훅으로 분리 * design: (#41) 로그아웃 버튼이 잘못 위치한 부분 수정 * refactor: (#41) 유저의 정보를 받는 props 변수명을 가독성을 위해 변경 user => userInfo * refactor: (#41) 코드 가독성과 예쁜 디자인을 위한 코드 수정 --- frontend/src/assets/chevron-down.svg | 3 + frontend/src/assets/chevron-up.svg | 3 + frontend/src/assets/kakao_login.svg | 9 ++ .../CategoryToggle/CategoryToggle.stories.tsx | 40 ++++++ .../common/Dashboard/CategoryToggle/index.tsx | 56 +++++++++ .../common/Dashboard/CategoryToggle/style.ts | 64 ++++++++++ .../common/Dashboard/Dashboard.stories.tsx | 116 ++++++++++++++++++ .../GuestProfile/GuestProfile.stories.tsx | 14 +++ .../common/Dashboard/GuestProfile/index.tsx | 19 +++ .../common/Dashboard/GuestProfile/style.ts | 20 +++ .../UserProfile/UserProfile.stories.tsx | 27 ++++ .../common/Dashboard/UserProfile/index.tsx | 38 ++++++ .../common/Dashboard/UserProfile/style.ts | 43 +++++++ .../src/components/common/Dashboard/index.tsx | 63 ++++++++++ .../common/Dashboard/profileStyle.ts | 16 +++ .../src/components/common/Dashboard/style.ts | 70 +++++++++++ .../common/Drawer/Drawer.stories.tsx | 67 ++++++++++ .../src/components/common/Drawer/index.tsx | 54 ++++++++ .../src/components/common/Drawer/style.ts | 25 ++++ .../NarrowMainHeader.stories.tsx | 2 +- .../common/NarrowMainHeader/index.tsx | 8 +- frontend/src/hooks/useDrawer.tsx | 34 +++++ frontend/src/styles/globalStyle.ts | 3 + frontend/src/types/category.ts | 5 + frontend/src/types/user.ts | 7 ++ 25 files changed, 803 insertions(+), 3 deletions(-) create mode 100644 frontend/src/assets/chevron-down.svg create mode 100644 frontend/src/assets/chevron-up.svg create mode 100644 frontend/src/assets/kakao_login.svg create mode 100644 frontend/src/components/common/Dashboard/CategoryToggle/CategoryToggle.stories.tsx create mode 100644 frontend/src/components/common/Dashboard/CategoryToggle/index.tsx create mode 100644 frontend/src/components/common/Dashboard/CategoryToggle/style.ts create mode 100644 frontend/src/components/common/Dashboard/Dashboard.stories.tsx create mode 100644 frontend/src/components/common/Dashboard/GuestProfile/GuestProfile.stories.tsx create mode 100644 frontend/src/components/common/Dashboard/GuestProfile/index.tsx create mode 100644 frontend/src/components/common/Dashboard/GuestProfile/style.ts create mode 100644 frontend/src/components/common/Dashboard/UserProfile/UserProfile.stories.tsx create mode 100644 frontend/src/components/common/Dashboard/UserProfile/index.tsx create mode 100644 frontend/src/components/common/Dashboard/UserProfile/style.ts create mode 100644 frontend/src/components/common/Dashboard/index.tsx create mode 100644 frontend/src/components/common/Dashboard/profileStyle.ts create mode 100644 frontend/src/components/common/Dashboard/style.ts create mode 100644 frontend/src/components/common/Drawer/Drawer.stories.tsx create mode 100644 frontend/src/components/common/Drawer/index.tsx create mode 100644 frontend/src/components/common/Drawer/style.ts create mode 100644 frontend/src/hooks/useDrawer.tsx create mode 100644 frontend/src/types/category.ts create mode 100644 frontend/src/types/user.ts diff --git a/frontend/src/assets/chevron-down.svg b/frontend/src/assets/chevron-down.svg new file mode 100644 index 000000000..ac802d948 --- /dev/null +++ b/frontend/src/assets/chevron-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/chevron-up.svg b/frontend/src/assets/chevron-up.svg new file mode 100644 index 000000000..48bf4b209 --- /dev/null +++ b/frontend/src/assets/chevron-up.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/kakao_login.svg b/frontend/src/assets/kakao_login.svg new file mode 100644 index 000000000..cbdb3098f --- /dev/null +++ b/frontend/src/assets/kakao_login.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/src/components/common/Dashboard/CategoryToggle/CategoryToggle.stories.tsx b/frontend/src/components/common/Dashboard/CategoryToggle/CategoryToggle.stories.tsx new file mode 100644 index 000000000..eccc0f7df --- /dev/null +++ b/frontend/src/components/common/Dashboard/CategoryToggle/CategoryToggle.stories.tsx @@ -0,0 +1,40 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { Category } from '@type/category'; + +import CategoryToggle from '.'; + +const meta: Meta = { + component: CategoryToggle, +}; + +export default meta; +type Story = StoryObj; + +const MOCK_CATEGORIES: Category[] = [ + { id: 12312, name: '음식', isFavorite: false }, + { id: 12, name: '연애', isFavorite: false }, + { id: 13, name: '패션', isFavorite: false }, + { id: 14, name: '금융', isFavorite: false }, +]; + +export const Default: Story = { + render: () => ( + {}} + title="즐겨찾기" + categoryList={MOCK_CATEGORIES} + /> + ), +}; + +export const Closed: Story = { + render: () => ( + {}} + title="즐겨찾기" + categoryList={MOCK_CATEGORIES} + isInitialOpen={false} + /> + ), +}; diff --git a/frontend/src/components/common/Dashboard/CategoryToggle/index.tsx b/frontend/src/components/common/Dashboard/CategoryToggle/index.tsx new file mode 100644 index 000000000..4bd54c5a3 --- /dev/null +++ b/frontend/src/components/common/Dashboard/CategoryToggle/index.tsx @@ -0,0 +1,56 @@ +import React, { useState } from 'react'; + +import { Category } from '@type/category'; + +import chevronDown from '@assets/chevron-down.svg'; +import chevronUp from '@assets/chevron-up.svg'; + +import * as S from './style'; + +interface CategoryToggleProps { + title: string; + categoryList: Category[]; + handleFavoriteClick: (categoryId: number) => void; + isInitialOpen?: boolean; +} + +export default function CategoryToggle({ + title, + categoryList, + handleFavoriteClick, + isInitialOpen = true, +}: CategoryToggleProps) { + const [isToggleOpen, setIsToggleOpen] = useState(isInitialOpen); + + const handleToggleClick = () => { + setIsToggleOpen(prevIsToggleOpen => !prevIsToggleOpen); + }; + + return ( + + + + {title} + + {isToggleOpen && ( + + {categoryList.length === 0 && 현재 카테고리가 없습니다} + {categoryList.map(({ id, name, isFavorite }) => ( + + handleFavoriteClick(id)} + $isFavorite={isFavorite} + /> + {name} + + ))} + + )} + + ); +} diff --git a/frontend/src/components/common/Dashboard/CategoryToggle/style.ts b/frontend/src/components/common/Dashboard/CategoryToggle/style.ts new file mode 100644 index 000000000..ee8b67510 --- /dev/null +++ b/frontend/src/components/common/Dashboard/CategoryToggle/style.ts @@ -0,0 +1,64 @@ +import { Link } from 'react-router-dom'; + +import { styled } from 'styled-components'; + +import { theme } from '@styles/theme'; + +export const Container = styled.div` + font: var(--text-caption); + + @media (min-width: ${theme.breakpoint.sm}) { + font: var(--text-body); + } +`; + +export const TitleContainer = styled.button` + display: flex; + align-items: center; + + font: inherit; + + cursor: pointer; +`; + +export const TriangleImage = styled.img` + width: 16px; + height: 16px; + margin-right: 8px; +`; + +export const CategoryList = styled.div` + display: flex; + flex-direction: column; + gap: 16px; + + padding: 16px 12px; +`; + +export const CategoryItem = styled.div` + display: flex; + align-items: center; +`; + +export const Circle = styled.button<{ $isFavorite: boolean }>` + width: 12px; + height: 12px; + border-radius: 50%; + margin-right: 12px; + + background-color: ${({ $isFavorite }) => ($isFavorite ? 'var(--primary-color)' : '#CCCCCC')}; + + cursor: pointer; +`; + +export const Caption = styled.span` + font: var(--text-caption); + + color: var(--dark-gray); +`; + +export const CategoryName = styled(Link)` + text-decoration: none; + + color: inherit; +`; diff --git a/frontend/src/components/common/Dashboard/Dashboard.stories.tsx b/frontend/src/components/common/Dashboard/Dashboard.stories.tsx new file mode 100644 index 000000000..331f20361 --- /dev/null +++ b/frontend/src/components/common/Dashboard/Dashboard.stories.tsx @@ -0,0 +1,116 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { Category } from '@type/category'; +import { User } from '@type/user'; + +import Dashboard from '.'; + +const meta: Meta = { + component: Dashboard, +}; + +export default meta; +type Story = StoryObj; + +const MOCK_USER_INFO: User = { + nickname: '우아한 코끼리', + postCount: 4, + voteCount: 128, + userPoint: 200, +}; + +const MOCK_CATEGORIES: Category[] = [ + { id: 12312, name: '음식', isFavorite: false }, + { id: 12, name: '연애', isFavorite: false }, + { id: 13, name: '패션', isFavorite: false }, + { id: 14, name: '금융', isFavorite: false }, +]; + +const MOCK_FAVORITE_CATEGORIES: Category[] = [ + { id: 12312, name: '음식', isFavorite: false }, + { id: 12, name: '연애', isFavorite: true }, + { id: 13, name: '패션', isFavorite: true }, + { id: 14, name: '금융', isFavorite: false }, +]; + +const MOCK_LONG_CATEGORIES: Category[] = [ + { id: 12312, name: '음식', isFavorite: false }, + { id: 12, name: '연애', isFavorite: true }, + { id: 13, name: '패션', isFavorite: true }, + { id: 14, name: '금융', isFavorite: false }, + { id: 12312, name: '음식', isFavorite: false }, + { id: 12, name: '연애', isFavorite: true }, + { id: 13, name: '패션', isFavorite: true }, + { id: 14, name: '금융', isFavorite: false }, + { id: 12312, name: '음식', isFavorite: false }, + { id: 12, name: '연애', isFavorite: true }, + { id: 13, name: '패션', isFavorite: true }, + { id: 14, name: '금융', isFavorite: false }, + { id: 12312, name: '음식', isFavorite: false }, + { id: 12, name: '연애', isFavorite: true }, + { id: 13, name: '패션', isFavorite: true }, + { id: 14, name: '금융', isFavorite: false }, + { id: 12312, name: '음식', isFavorite: false }, + { id: 12, name: '연애', isFavorite: true }, + { id: 13, name: '패션', isFavorite: true }, + { id: 14, name: '금융', isFavorite: false }, + { id: 12312, name: '음식', isFavorite: false }, + { id: 12, name: '연애', isFavorite: true }, + { id: 13, name: '패션', isFavorite: true }, + { id: 14, name: '금융', isFavorite: false }, +]; + +export const LoggedIn: Story = { + render: () => ( + {}} + handleLogoutClick={() => {}} + /> + ), +}; + +export const FavoriteCategory: Story = { + render: () => ( + {}} + handleLogoutClick={() => {}} + /> + ), +}; + +export const SelectedCategory: Story = { + render: () => ( + {}} + handleLogoutClick={() => {}} + /> + ), +}; + +export const LongCategoryList: Story = { + render: () => ( + {}} + handleLogoutClick={() => {}} + /> + ), +}; + +export const Guest: Story = { + render: () => ( + {}} + handleLogoutClick={() => {}} + /> + ), +}; diff --git a/frontend/src/components/common/Dashboard/GuestProfile/GuestProfile.stories.tsx b/frontend/src/components/common/Dashboard/GuestProfile/GuestProfile.stories.tsx new file mode 100644 index 000000000..9920f2a20 --- /dev/null +++ b/frontend/src/components/common/Dashboard/GuestProfile/GuestProfile.stories.tsx @@ -0,0 +1,14 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import GuestProfile from '.'; + +const meta: Meta = { + component: GuestProfile, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => , +}; diff --git a/frontend/src/components/common/Dashboard/GuestProfile/index.tsx b/frontend/src/components/common/Dashboard/GuestProfile/index.tsx new file mode 100644 index 000000000..8154d6737 --- /dev/null +++ b/frontend/src/components/common/Dashboard/GuestProfile/index.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; + +import { BASE_PATH } from '@constants/path'; + +import kakaoLogin from '@assets/kakao_login.svg'; + +import * as S from './style'; + +export default function GuestProfile() { + return ( + + + + + 로그인 후 이용할 수 있습니다 + + ); +} diff --git a/frontend/src/components/common/Dashboard/GuestProfile/style.ts b/frontend/src/components/common/Dashboard/GuestProfile/style.ts new file mode 100644 index 000000000..c9eeb1814 --- /dev/null +++ b/frontend/src/components/common/Dashboard/GuestProfile/style.ts @@ -0,0 +1,20 @@ +import { styled } from 'styled-components'; + +import { ProfileContainer } from '../profileStyle'; + +export const Container = styled(ProfileContainer)` + align-items: center; +`; + +export const Image = styled.img` + width: 183px; + height: 40px; +`; + +export const TextCard = styled.span` + margin-top: 20px; + + font: var(--text-caption); + + color: var(--dark-gray); +`; diff --git a/frontend/src/components/common/Dashboard/UserProfile/UserProfile.stories.tsx b/frontend/src/components/common/Dashboard/UserProfile/UserProfile.stories.tsx new file mode 100644 index 000000000..cb71eef9c --- /dev/null +++ b/frontend/src/components/common/Dashboard/UserProfile/UserProfile.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { User } from '@type/user'; + +import UserProfile from '.'; + +const meta: Meta = { + component: UserProfile, +}; + +export default meta; +type Story = StoryObj; + +const MOCK_USER_INFO: User = { + nickname: '우아한 코끼리', + postCount: 4, + voteCount: 128, + userPoint: 200, +}; + +export const NoBadge: Story = { + render: () => , +}; + +export const Badge: Story = { + render: () => , +}; diff --git a/frontend/src/components/common/Dashboard/UserProfile/index.tsx b/frontend/src/components/common/Dashboard/UserProfile/index.tsx new file mode 100644 index 000000000..035dd2142 --- /dev/null +++ b/frontend/src/components/common/Dashboard/UserProfile/index.tsx @@ -0,0 +1,38 @@ +import React from 'react'; + +import { BASE_PATH } from '@constants/path'; + +import { User } from '@type/user'; + +import * as PS from '../profileStyle'; + +import * as S from './style'; + +interface UserProfileProps { + userInfo: User; +} + +export default function UserProfile({ userInfo }: UserProfileProps) { + const { nickname, userPoint, postCount, voteCount, badge } = userInfo; + + return ( + + {badge && [{badge}]} + {nickname} + + + 포인트 + {userPoint} + + + 작성글 + {postCount} + + + 투표수 + {voteCount} + + + + ); +} diff --git a/frontend/src/components/common/Dashboard/UserProfile/style.ts b/frontend/src/components/common/Dashboard/UserProfile/style.ts new file mode 100644 index 000000000..3d42424a4 --- /dev/null +++ b/frontend/src/components/common/Dashboard/UserProfile/style.ts @@ -0,0 +1,43 @@ +import { Link } from 'react-router-dom'; + +import { styled } from 'styled-components'; + +export const Badge = styled.span` + margin-bottom: 7px; +`; + +export const NickName = styled.span` + margin-bottom: 12px; + + font: var(--text-title); + + color: var(--red); +`; + +export const UserInfoContainer = styled.div` + display: flex; + justify-content: space-between; +`; + +export const TextCardContainer = styled.div` + display: flex; + flex-direction: column; +`; + +export const TextCardLink = styled(Link)` + display: flex; + flex-direction: column; + + text-decoration: none; + + color: initial; +`; + +export const TextCardTitle = styled.span` + font: var(--text-caption); +`; + +export const TextCardContent = styled.span` + font: var(--text-caption); + text-align: center; +`; diff --git a/frontend/src/components/common/Dashboard/index.tsx b/frontend/src/components/common/Dashboard/index.tsx new file mode 100644 index 000000000..e9b70c575 --- /dev/null +++ b/frontend/src/components/common/Dashboard/index.tsx @@ -0,0 +1,63 @@ +import React from 'react'; + +import { Category } from '@type/category'; +import { User } from '@type/user'; + +import SquareButton from '../SquareButton'; + +import CategoryToggle from './CategoryToggle'; +import GuestProfile from './GuestProfile'; +import * as S from './style'; +import UserProfile from './UserProfile'; + +interface DashboardProps { + categoryList: Category[]; + selectedCategory?: string; + handleFavoriteClick: (categoryId: number) => void; + handleLogoutClick: () => void; + userInfo?: User; +} + +export default function Dashboard({ + userInfo, + categoryList, + selectedCategory = '전체', + handleFavoriteClick, + handleLogoutClick, +}: DashboardProps) { + const favoriteCategory = categoryList.filter(category => category.isFavorite === true); + const allCategory = categoryList.filter(category => category.isFavorite === false); + + return ( + + {userInfo ? : } + + + {selectedCategory} + + + + {userInfo && ( + + )} + + + + {userInfo && ( + + + 로그아웃 + + + )} + + ); +} diff --git a/frontend/src/components/common/Dashboard/profileStyle.ts b/frontend/src/components/common/Dashboard/profileStyle.ts new file mode 100644 index 000000000..060ff6e1a --- /dev/null +++ b/frontend/src/components/common/Dashboard/profileStyle.ts @@ -0,0 +1,16 @@ +import { styled } from 'styled-components'; + +export const ProfileContainer = styled.div` + display: flex; + flex-direction: column; + justify-content: end; + + width: 100%; + height: 130px; + padding: 16px 12px; + border-radius: 4px; + + font-size: 1.6rem; + + background-color: var(--gray); +`; diff --git a/frontend/src/components/common/Dashboard/style.ts b/frontend/src/components/common/Dashboard/style.ts new file mode 100644 index 000000000..cb587dfdd --- /dev/null +++ b/frontend/src/components/common/Dashboard/style.ts @@ -0,0 +1,70 @@ +import { styled } from 'styled-components'; + +export const Container = styled.div` + display: flex; + flex-direction: column; + align-items: center; + + width: 225px; + height: 100vh; + padding: 20px; + border-right: 2px solid var(--gray); +`; + +export const ContentContainer = styled.div` + display: flex; + flex-direction: column; + align-items: start; + + width: 100%; + margin-bottom: 85px; + + overflow-y: scroll; + + -ms-overflow-style: none; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } +`; + +export const ButtonWrapper = styled.div` + width: 90px; + height: 40px; + + position: absolute; + bottom: 30px; +`; + +export const SelectCategoryWrapper = styled.div` + display: flex; + align-items: center; + gap: 12px; + justify-self: start; + + width: 100%; + border-bottom: 2px solid var(--gray); + padding-bottom: 20px; + margin-top: 32px; +`; + +export const Circle = styled.div` + width: 12px; + height: 12px; + border-radius: 50%; + + background-color: var(--red); +`; + +export const SelectCategoryText = styled.span` + font: var(--text-body); +`; + +export const CategoryToggleContainer = styled.div` + display: flex; + flex-direction: column; + gap: 20px; + + padding-top: 20px; +`; diff --git a/frontend/src/components/common/Drawer/Drawer.stories.tsx b/frontend/src/components/common/Drawer/Drawer.stories.tsx new file mode 100644 index 000000000..181c68b3a --- /dev/null +++ b/frontend/src/components/common/Drawer/Drawer.stories.tsx @@ -0,0 +1,67 @@ +import type { Meta } from '@storybook/react'; + +import { Category } from '@type/category'; +import { User } from '@type/user'; + +import { useDrawer } from '@hooks/useDrawer'; + +import Dashboard from '../Dashboard'; +import NarrowMainHeader from '../NarrowMainHeader'; + +import Drawer from '.'; + +const meta: Meta = { + component: Drawer, +}; + +export default meta; + +const MOCK_USER_INFO: User = { + nickname: '우아한 코끼리', + postCount: 4, + voteCount: 128, + userPoint: 200, +}; + +const MOCK_CATEGORIES: Category[] = [ + { id: 12312, name: '음식', favorite: false }, + { id: 12, name: '연애', favorite: false }, + { id: 13, name: '패션', favorite: false }, + { id: 14, name: '금융', favorite: false }, +]; + +export const LeftSideBar = () => { + const { drawerRef, openDrawer, closeDrawer } = useDrawer('left'); + + return ( +
+ + + {}} + handleLogoutClick={() => {}} + /> + +
+ ); +}; + +export const RightSideBar = () => { + const { drawerRef, openDrawer, closeDrawer } = useDrawer('right'); + + return ( +
+ + + {}} + handleLogoutClick={() => {}} + /> + +
+ ); +}; diff --git a/frontend/src/components/common/Drawer/index.tsx b/frontend/src/components/common/Drawer/index.tsx new file mode 100644 index 000000000..ef8670c07 --- /dev/null +++ b/frontend/src/components/common/Drawer/index.tsx @@ -0,0 +1,54 @@ +import React, { + ForwardedRef, + KeyboardEvent, + MouseEvent, + PropsWithChildren, + forwardRef, +} from 'react'; + +import * as S from './style'; + +interface DrawerProps extends PropsWithChildren { + handleDrawerClose: () => void; + width: string; + placement: 'left' | 'right'; +} + +export default forwardRef(function Drawer( + { handleDrawerClose, width, placement, children }: DrawerProps, + ref: ForwardedRef +) { + const handleCloseClick = (event: MouseEvent) => { + const modalBoundary = event.currentTarget.getBoundingClientRect(); + + if ( + modalBoundary.left > event.clientX || + modalBoundary.right < event.clientX || + modalBoundary.top > event.clientY || + modalBoundary.bottom < event.clientY + ) { + handleDrawerClose(); + } + }; + + const handleKeyDown = (event: KeyboardEvent) => { + event.preventDefault(); + + if (event.currentTarget.open && event.key === 'Escape') { + handleDrawerClose(); + } + }; + + return ( + + {children} + + ); +}); diff --git a/frontend/src/components/common/Drawer/style.ts b/frontend/src/components/common/Drawer/style.ts new file mode 100644 index 000000000..c1ef81e67 --- /dev/null +++ b/frontend/src/components/common/Drawer/style.ts @@ -0,0 +1,25 @@ +import { styled } from 'styled-components'; + +export const Dialog = styled.dialog<{ + $width: string; + $placement: 'left' | 'right'; +}>` + width: ${({ $width }) => $width}; + height: 100%; + + position: fixed; + top: 0; + left: ${({ $placement }) => ($placement === 'left' ? '0' : 'auto')}; + right: ${({ $placement }) => ($placement === 'right' ? '0' : 'auto')}; + + overflow: hidden; + + transition-property: transform; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; + + z-index: 999; + &::backdrop { + background-color: rgba(0, 0, 0, 0.35); + } +`; diff --git a/frontend/src/components/common/NarrowMainHeader/NarrowMainHeader.stories.tsx b/frontend/src/components/common/NarrowMainHeader/NarrowMainHeader.stories.tsx index cf0410cba..33986112a 100644 --- a/frontend/src/components/common/NarrowMainHeader/NarrowMainHeader.stories.tsx +++ b/frontend/src/components/common/NarrowMainHeader/NarrowMainHeader.stories.tsx @@ -11,5 +11,5 @@ export default meta; type Story = StoryObj; export const Primary: Story = { - render: () => , + render: () => {}} />, }; diff --git a/frontend/src/components/common/NarrowMainHeader/index.tsx b/frontend/src/components/common/NarrowMainHeader/index.tsx index 3e783b531..944fd047f 100644 --- a/frontend/src/components/common/NarrowMainHeader/index.tsx +++ b/frontend/src/components/common/NarrowMainHeader/index.tsx @@ -3,10 +3,14 @@ import LogoButton from '../LogoButton'; import * as S from './style'; -export default function NarrowMainHeader() { +interface NarrowMainHeaderProps { + handleMenuOpenClick: () => void; +} + +export default function NarrowMainHeader({ handleMenuOpenClick }: NarrowMainHeaderProps) { return ( - + diff --git a/frontend/src/hooks/useDrawer.tsx b/frontend/src/hooks/useDrawer.tsx new file mode 100644 index 000000000..ad40fc2f2 --- /dev/null +++ b/frontend/src/hooks/useDrawer.tsx @@ -0,0 +1,34 @@ +import { useEffect, useRef } from 'react'; + +export const useDrawer = (placement: 'left' | 'right') => { + const drawerRef = useRef(null); + + const openDrawer = () => { + if (!drawerRef.current) return; + + drawerRef.current.showModal(); + drawerRef.current.style.transform = 'translateX(0)'; + }; + + const closeDrawer = () => { + if (!drawerRef.current) return; + + drawerRef.current.style.transform = + placement === 'left' ? 'translateX(-100%)' : 'translateX(100%)'; + + setTimeout(() => { + if (!drawerRef.current) return; + + drawerRef.current.close(); + }, 300); + }; + + useEffect(() => { + if (!drawerRef.current) return; + + drawerRef.current.style.transform = + placement === 'left' ? 'translateX(-100%)' : 'translateX(100%)'; + }, []); + + return { drawerRef, openDrawer, closeDrawer }; +}; diff --git a/frontend/src/styles/globalStyle.ts b/frontend/src/styles/globalStyle.ts index 5032b2da2..138fcddfe 100644 --- a/frontend/src/styles/globalStyle.ts +++ b/frontend/src/styles/globalStyle.ts @@ -28,6 +28,9 @@ export const GlobalStyle = createGlobalStyle` :root { --primary-color: #FA7D7C; --white: #FFFFFF; + --gray: #F4F4F4; + --red: #F51A18; + --dark-gray: #929292; --text-title: 600 20px/24px san-serif; --text-subtitle: 600 18px/28px san-serif; diff --git a/frontend/src/types/category.ts b/frontend/src/types/category.ts new file mode 100644 index 000000000..774301439 --- /dev/null +++ b/frontend/src/types/category.ts @@ -0,0 +1,5 @@ +export interface Category { + id: number; + name: string; + isFavorite: boolean; +} diff --git a/frontend/src/types/user.ts b/frontend/src/types/user.ts new file mode 100644 index 000000000..22cbff9c6 --- /dev/null +++ b/frontend/src/types/user.ts @@ -0,0 +1,7 @@ +export interface User { + nickname: string; + userPoint: number; + postCount: number; + voteCount: number; + badge?: string; +} From 7810978748e3aa2bb0dcb7dffc240c93f34f9c16 Mon Sep 17 00:00:00 2001 From: lookh <103165859+aiaiaiai1@users.noreply.github.com> Date: Tue, 18 Jul 2023 11:10:07 +0900 Subject: [PATCH 4/5] =?UTF-8?q?=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=EC=A7=80=20=ED=88=AC=ED=91=9C=EA=B8=B0=EB=8A=A5,=20?= =?UTF-8?q?=ED=88=AC=ED=91=9C=20=EC=88=98=EC=A0=95=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#47)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: (#36) Vote 리파지터리 추가 * feat: (#36) 투표하는 기능, 포인트획득 기능 추가 * feat: (#36) 식별자를 통해 PostOption 찾는 기능 추가 - PostOption과 양뱡향 매핑 관계 등록 * feat: (#36) 자신이 작성한 글에는 투표하지 못하는 검증 추가 * feat: (#36) 게시글에 투표하는 API 기능 추가 * feat: (#36) 게시글에 투표수정 하는 API 기능 추가 * feat: (#36) 게시글 마감기간 검증 추가 * feat: (#36) 투표 수정시 같은 게시글인지 검증하는 로직 추가 * test: (#36) 테스트 코드 추가 * style: (#36) final 키워드 추가 및 어노테이션 순서 위치 변경 * feat: swagger 어노테이션 적용 * feat: 검증 기능 추가 - 투표 할때 이미 게시글에 투표를 했는지 검증하는 기능 - 투표 수정할때 기존에 투표가 존재하는지 검증하는 기능 * refactor: (#36) 멤버가 아닌 게시글을 통해 투표객체 만들도록 수정 * refactor: (#36) PostOptionRepository 추가, 투표 수정시 투표 엔티티 삭제후 저장하는 방식으로 수정 * test: (#36) 테스트코드 일부 추가 * fix: (#36) 필드 수정 --- .../domain/member/entity/Member.java | 4 + .../votogether/domain/post/entity/Post.java | 48 ++++++ .../post/repository/PostOptionRepository.java | 7 + .../vote/controller/VoteController.java | 50 ++++++ .../votogether/domain/vote/entity/Vote.java | 2 +- .../vote/repository/VoteRepository.java | 16 ++ .../domain/vote/service/VoteService.java | 72 +++++++++ .../domain/post/entity/PostTest.java | 73 +++++++++ .../vote/repository/VoteRepositoryTest.java | 146 ++++++++++++++++++ 9 files changed, 417 insertions(+), 1 deletion(-) create mode 100644 backend/src/main/java/com/votogether/domain/post/repository/PostOptionRepository.java create mode 100644 backend/src/main/java/com/votogether/domain/vote/controller/VoteController.java create mode 100644 backend/src/main/java/com/votogether/domain/vote/repository/VoteRepository.java create mode 100644 backend/src/main/java/com/votogether/domain/vote/service/VoteService.java create mode 100644 backend/src/test/java/com/votogether/domain/post/entity/PostTest.java create mode 100644 backend/src/test/java/com/votogether/domain/vote/repository/VoteRepositoryTest.java diff --git a/backend/src/main/java/com/votogether/domain/member/entity/Member.java b/backend/src/main/java/com/votogether/domain/member/entity/Member.java index 2f3611c43..d21e3dc94 100644 --- a/backend/src/main/java/com/votogether/domain/member/entity/Member.java +++ b/backend/src/main/java/com/votogether/domain/member/entity/Member.java @@ -60,4 +60,8 @@ private Member( this.point = point; } + public void plusPoint(final int point) { + this.point = this.point + point; + } + } diff --git a/backend/src/main/java/com/votogether/domain/post/entity/Post.java b/backend/src/main/java/com/votogether/domain/post/entity/Post.java index 2f5643a94..9995058fe 100644 --- a/backend/src/main/java/com/votogether/domain/post/entity/Post.java +++ b/backend/src/main/java/com/votogether/domain/post/entity/Post.java @@ -2,6 +2,7 @@ import com.votogether.domain.common.BaseEntity; import com.votogether.domain.member.entity.Member; +import com.votogether.domain.vote.entity.Vote; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; @@ -10,7 +11,10 @@ import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -38,6 +42,9 @@ public class Post extends BaseEntity { @Column(columnDefinition = "datetime(2)", nullable = false) private LocalDateTime deadline; + @OneToMany(mappedBy = "post") + private List postOptions = new ArrayList<>(); + @Builder private Post( final Member member, @@ -51,4 +58,45 @@ private Post( this.deadline = deadline; } + public boolean hasPostOption(final PostOption postOption) { + return postOptions.contains(postOption); + } + + public boolean isWriter(final Member member) { + return this.member == member; + } + + public boolean isClosed() { + return deadline.isBefore(LocalDateTime.now()); + } + + public Vote makeVote(Member member, PostOption postOption) { + validateDeadLine(); + validateWriter(member); + validatePostOption(postOption); + + return Vote.builder() + .member(member) + .postOption(postOption) + .build(); + } + + private void validateDeadLine() { + if (isClosed()) { + throw new IllegalStateException("게시글이 이미 마감되었습니다."); + } + } + + private void validateWriter(Member member) { + if (isWriter(member)) { + throw new IllegalArgumentException("작성자는 투표할 수 없습니다."); + } + } + + private void validatePostOption(PostOption postOption) { + if (!hasPostOption(postOption)) { + throw new IllegalArgumentException("해당 게시글에서 존재하지 않는 선택지 입니다."); + } + } + } diff --git a/backend/src/main/java/com/votogether/domain/post/repository/PostOptionRepository.java b/backend/src/main/java/com/votogether/domain/post/repository/PostOptionRepository.java new file mode 100644 index 000000000..c208de238 --- /dev/null +++ b/backend/src/main/java/com/votogether/domain/post/repository/PostOptionRepository.java @@ -0,0 +1,7 @@ +package com.votogether.domain.post.repository; + +import com.votogether.domain.post.entity.PostOption; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PostOptionRepository extends JpaRepository { +} diff --git a/backend/src/main/java/com/votogether/domain/vote/controller/VoteController.java b/backend/src/main/java/com/votogether/domain/vote/controller/VoteController.java new file mode 100644 index 000000000..de3178d34 --- /dev/null +++ b/backend/src/main/java/com/votogether/domain/vote/controller/VoteController.java @@ -0,0 +1,50 @@ +package com.votogether.domain.vote.controller; + +import com.votogether.domain.member.entity.Member; +import com.votogether.domain.vote.service.VoteService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@Tag(name = "투표", description = "투표 API") +@RequiredArgsConstructor +public class VoteController { + + private final VoteService voteService; + + @Operation(summary = "투표하기", description = "게시글의 선택지에 투표를 한다.") + @ApiResponse(responseCode = "201", description = "투표 성공") + @PostMapping("/posts/{postId}/options/{optionId}") + public ResponseEntity vote( + @PathVariable final Long postId, + @PathVariable("optionId") final Long postOptionId, + final Member member + ) { + voteService.vote(member, postId, postOptionId); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + + @Operation(summary = "투표 수정하기", description = "게시글의 선택지의 투표를 수정한다.") + @ApiResponse(responseCode = "200", description = "투표 수정 성공") + @PatchMapping("/posts/{postId}/options") + public ResponseEntity changeVote( + @PathVariable final Long postId, + @RequestParam("source") final Long originPostOptionId, + @RequestParam("target") final Long newPostOptionId, + final Member member + ) { + voteService.changeVote(member, postId, originPostOptionId, newPostOptionId); + return ResponseEntity.status(HttpStatus.OK).build(); + } + +} diff --git a/backend/src/main/java/com/votogether/domain/vote/entity/Vote.java b/backend/src/main/java/com/votogether/domain/vote/entity/Vote.java index a5a4afdb8..f4bf1bbbc 100644 --- a/backend/src/main/java/com/votogether/domain/vote/entity/Vote.java +++ b/backend/src/main/java/com/votogether/domain/vote/entity/Vote.java @@ -15,9 +15,9 @@ import lombok.Getter; import lombok.NoArgsConstructor; +@Entity @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter -@Entity public class Vote extends BaseEntity { @Id diff --git a/backend/src/main/java/com/votogether/domain/vote/repository/VoteRepository.java b/backend/src/main/java/com/votogether/domain/vote/repository/VoteRepository.java new file mode 100644 index 000000000..d896c8dbe --- /dev/null +++ b/backend/src/main/java/com/votogether/domain/vote/repository/VoteRepository.java @@ -0,0 +1,16 @@ +package com.votogether.domain.vote.repository; + +import com.votogether.domain.member.entity.Member; +import com.votogether.domain.post.entity.PostOption; +import com.votogether.domain.vote.entity.Vote; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface VoteRepository extends JpaRepository { + + Optional findByMemberAndPostOption(final Member member, final PostOption postOption); + + List findByMemberAndPostOptionIn(final Member member, final List postOptions); + +} 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 new file mode 100644 index 000000000..f8a502f3f --- /dev/null +++ b/backend/src/main/java/com/votogether/domain/vote/service/VoteService.java @@ -0,0 +1,72 @@ +package com.votogether.domain.vote.service; + +import com.votogether.domain.member.entity.Member; +import com.votogether.domain.post.entity.Post; +import com.votogether.domain.post.entity.PostOption; +import com.votogether.domain.post.repository.PostOptionRepository; +import com.votogether.domain.post.repository.PostRepository; +import com.votogether.domain.vote.entity.Vote; +import com.votogether.domain.vote.repository.VoteRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class VoteService { + + private final VoteRepository voteRepository; + private final PostRepository postRepository; + private final PostOptionRepository postOptionRepository; + + public void vote( + final Member member, + final Long postId, + final Long postOptionId + ) { + Post post = postRepository.findById(postId) + .orElseThrow(() -> new IllegalArgumentException("해당 게시글이 존재하지 않습니다.")); + + validateAlreadyVoted(member, post); + + PostOption postOption = postOptionRepository.findById(postOptionId) + .orElseThrow(() -> new IllegalArgumentException("해당 선택지가 존재하지 않습니다.")); + + Vote vote = post.makeVote(member, postOption); + member.plusPoint(1); + voteRepository.save(vote); + } + + private void validateAlreadyVoted(Member member, Post post) { + List alreadyVoted = voteRepository.findByMemberAndPostOptionIn(member, post.getPostOptions()); + if (!alreadyVoted.isEmpty()) { + throw new IllegalStateException("해당 게시물에는 이미 투표하였습니다."); + } + } + + public void changeVote( + final Member member, + final Long postId, + final Long originPostOptionId, + final Long newPostOptionId + ) { + Post post = postRepository.findById(postId) + .orElseThrow(() -> new IllegalArgumentException("해당 게시글이 존재하지 않습니다.")); + + PostOption originPostOption = postOptionRepository.findById(originPostOptionId) + .orElseThrow(() -> new IllegalArgumentException("헤당 선택지가 존재하지 않습니다.")); + + Vote originVote = voteRepository.findByMemberAndPostOption(member, originPostOption) + .orElseThrow(() -> new IllegalArgumentException("선택지에 해당되는 투표가 존재하지 않습니다.")); + + PostOption newPostOption = postOptionRepository.findById(newPostOptionId) + .orElseThrow(() -> new IllegalArgumentException("헤당 선택지가 존재하지 않습니다.")); + + voteRepository.delete(originVote); + Vote vote = post.makeVote(member, newPostOption); + voteRepository.save(vote); + } + +} diff --git a/backend/src/test/java/com/votogether/domain/post/entity/PostTest.java b/backend/src/test/java/com/votogether/domain/post/entity/PostTest.java new file mode 100644 index 000000000..39cdeb8da --- /dev/null +++ b/backend/src/test/java/com/votogether/domain/post/entity/PostTest.java @@ -0,0 +1,73 @@ +package com.votogether.domain.post.entity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.votogether.domain.member.entity.Gender; +import com.votogether.domain.member.entity.Member; +import com.votogether.domain.member.entity.SocialType; +import java.time.LocalDateTime; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class PostTest { + + @Test + @DisplayName("게시글의 작성자 여부를 확인한다.") + void isWriter() { + // given + Member member1 = 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(); + + Member member2 = Member.builder() + .nickname("s") + .build(); + + Post post = Post.builder() + .member(member1) + .build(); + + // when + boolean result1 = post.isWriter(member1); + boolean result2 = post.isWriter(member2); + + // then + assertAll( + () -> assertThat(result1).isTrue(), + () -> assertThat(result2).isFalse() + ); + } + + @Test + @DisplayName("게시글의 마감 여부를 확인한다.") + void isClosed() { + // given + Post post1 = Post.builder() + .deadline( + LocalDateTime.of(2022, 01, 01, 0, 0)) + .build(); + + Post post2 = Post.builder() + .deadline( + LocalDateTime.of(3222, 01, 01, 0, 0)) + .build(); + + // when + boolean result1 = post1.isClosed(); + boolean result2 = post2.isClosed(); + + // then + assertAll( + () -> assertThat(result1).isTrue(), + () -> assertThat(result2).isFalse() + ); + } + +} diff --git a/backend/src/test/java/com/votogether/domain/vote/repository/VoteRepositoryTest.java b/backend/src/test/java/com/votogether/domain/vote/repository/VoteRepositoryTest.java new file mode 100644 index 000000000..318d61898 --- /dev/null +++ b/backend/src/test/java/com/votogether/domain/vote/repository/VoteRepositoryTest.java @@ -0,0 +1,146 @@ +package com.votogether.domain.vote.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.votogether.config.JpaConfig; +import com.votogether.domain.member.entity.Gender; +import com.votogether.domain.member.entity.Member; +import com.votogether.domain.member.entity.SocialType; +import com.votogether.domain.member.repository.MemberRepository; +import com.votogether.domain.post.entity.Post; +import com.votogether.domain.post.entity.PostOption; +import com.votogether.domain.post.repository.PostOptionRepository; +import com.votogether.domain.post.repository.PostRepository; +import com.votogether.domain.vote.entity.Vote; +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; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; + +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@DataJpaTest +@Import(JpaConfig.class) +class VoteRepositoryTest { + + @Autowired + MemberRepository memberRepository; + + @Autowired + PostRepository postRepository; + + @Autowired + VoteRepository voteRepository; + + @Autowired + PostOptionRepository postOptionRepository; + + 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(); + + Post post1 = Post.builder() + .title("title") + .deadline( + LocalDateTime.of(3023, 07, 12, 00, 00)) + .content("content") + .member(member) + .build(); + + Post post2 = Post.builder() + .title("title2") + .deadline( + LocalDateTime.of(3023, 07, 12, 00, 00)) + .content("content2") + .member(member) + .build(); + PostOption postOption1 = PostOption.builder() + .post(post1) + .sequence(1) + .content("content1") + .build(); + + PostOption postOption2 = PostOption.builder() + .post(post2) + .sequence(2) + .content("content2") + .build(); + + @Test + @DisplayName("투표를 저장한다.") + void save() { + // given + Vote vote = Vote.builder() + .postOption(postOption1) + .member(member) + .build(); + memberRepository.save(member); + postRepository.save(post1); + postOptionRepository.save(postOption1); + + // when + voteRepository.save(vote); + + // then + assertThat(vote.getId()).isNotNull(); + } + + @Test + @DisplayName("멤버와 투표선택지를 통해 투표를 찾는다.") + void findByMemberAndPostOption() { + // given + Vote vote = Vote.builder() + .postOption(postOption1) + .member(member) + .build(); + memberRepository.save(member); + postRepository.save(post1); + postOptionRepository.save(postOption1); + voteRepository.save(vote); + + // when + Vote findVote = voteRepository.findByMemberAndPostOption(member, postOption1).get(); + + // then + assertThat(findVote).isSameAs(vote); + } + + @Test + @DisplayName("멤버와 여러 투표선택지를 통해 투표를 찾는다.") + void findByMemberAndPostOptionIn() { + // given + memberRepository.save(member); + postRepository.save(post1); + postRepository.save(post2); + postOptionRepository.save(postOption1); + postOptionRepository.save(postOption2); + + Vote vote1 = Vote.builder() + .postOption(postOption1) + .member(member) + .build(); + voteRepository.save(vote1); + + Vote vote2 = Vote.builder() + .postOption(postOption2) + .member(member) + .build(); + voteRepository.save(vote2); + + // when + List votes = voteRepository.findByMemberAndPostOptionIn(member, List.of(postOption1, postOption2)); + + // then + assertThat(votes).hasSize(2); + } + +} From 21e1b45724dd4e07a579bedcd9ee7cd2798a730b Mon Sep 17 00:00:00 2001 From: lookh <103165859+aiaiaiai1@users.noreply.github.com> Date: Tue, 18 Jul 2023 11:38:26 +0900 Subject: [PATCH 5/5] =?UTF-8?q?(=EB=B9=84=ED=9A=8C=EC=9B=90)=20=EC=A0=84?= =?UTF-8?q?=EC=B2=B4=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20API=20=EA=B5=AC=ED=98=84=20(#5?= =?UTF-8?q?2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: (#50) (비회원) 전체 카테고리 목록 조회 API 추가 * style: (#50) 개행 삭제 * refactor: (#50) 레코드 적용 * refactor: (#50) 개행 수정 및 Stream.toList() 사용 * style: (#50) 디스플레이 네임 오타 수정 * refactor: (#50) 커스터마이징 어노테이션 추가 * refactor: (#50) @Nested 적용 * feat: (#50) Swagger 어노테이션 추가 --- .../contorller/CategoryController.java | 32 +++++++ .../dto/response/CategoryResponse.java | 15 ++++ .../domain/category/entity/Category.java | 6 +- .../category/service/CategoryService.java | 26 ++++++ .../com/votogether/domain/RepositoryTest.java | 18 ++++ .../repository/CategoryRepositoryTest.java | 84 +++++++++++++++++++ 6 files changed, 179 insertions(+), 2 deletions(-) create mode 100644 backend/src/main/java/com/votogether/domain/category/contorller/CategoryController.java create mode 100644 backend/src/main/java/com/votogether/domain/category/dto/response/CategoryResponse.java create mode 100644 backend/src/main/java/com/votogether/domain/category/service/CategoryService.java create mode 100644 backend/src/test/java/com/votogether/domain/RepositoryTest.java create mode 100644 backend/src/test/java/com/votogether/domain/category/repository/CategoryRepositoryTest.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 new file mode 100644 index 000000000..9793aaaf4 --- /dev/null +++ b/backend/src/main/java/com/votogether/domain/category/contorller/CategoryController.java @@ -0,0 +1,32 @@ +package com.votogether.domain.category.contorller; + +import com.votogether.domain.category.dto.response.CategoryResponse; +import com.votogether.domain.category.service.CategoryService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +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.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@Tag(name = "카테고리", description = "카테고리 API") +@RequestMapping("/categories") +@RequiredArgsConstructor +public class CategoryController { + + private final CategoryService categoryService; + + @Operation(summary = "카테고리 조회하기", description = "전체 카테고리 목록을 조회한다.") + @ApiResponse(responseCode = "200", description = "조회 성공") + @GetMapping("/guest") + public ResponseEntity> getAllCategories() { + List categories = categoryService.getAllCategories(); + return ResponseEntity.status(HttpStatus.OK).body(categories); + } + +} 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 new file mode 100644 index 000000000..6ad58c3b3 --- /dev/null +++ b/backend/src/main/java/com/votogether/domain/category/dto/response/CategoryResponse.java @@ -0,0 +1,15 @@ +package com.votogether.domain.category.dto.response; + +import com.votogether.domain.category.entity.Category; + +public record CategoryResponse( + Long id, + String name, + boolean isFavorite +) { + + public CategoryResponse(final Category category) { + this(category.getId(), category.getName(), false); + } + +} diff --git a/backend/src/main/java/com/votogether/domain/category/entity/Category.java b/backend/src/main/java/com/votogether/domain/category/entity/Category.java index 454ef4c19..b11c402e5 100644 --- a/backend/src/main/java/com/votogether/domain/category/entity/Category.java +++ b/backend/src/main/java/com/votogether/domain/category/entity/Category.java @@ -8,19 +8,21 @@ import jakarta.persistence.Id; import lombok.AccessLevel; import lombok.Builder; +import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NoArgsConstructor; +@Entity @NoArgsConstructor(access = AccessLevel.PROTECTED) +@EqualsAndHashCode(of = {"name"}) @Getter -@Entity public class Category extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Column(length = 50, nullable = false) + @Column(length = 50, unique = true, nullable = false) private String name; @Builder 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 new file mode 100644 index 000000000..3dfd48c06 --- /dev/null +++ b/backend/src/main/java/com/votogether/domain/category/service/CategoryService.java @@ -0,0 +1,26 @@ +package com.votogether.domain.category.service; + +import com.votogether.domain.category.dto.response.CategoryResponse; +import com.votogether.domain.category.entity.Category; +import com.votogether.domain.category.repository.CategoryRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class CategoryService { + + private final CategoryRepository categoryRepository; + + @Transactional(readOnly = true) + public List getAllCategories() { + List categories = categoryRepository.findAll(); + + return categories.stream() + .map(CategoryResponse::new) + .toList(); + } + +} diff --git a/backend/src/test/java/com/votogether/domain/RepositoryTest.java b/backend/src/test/java/com/votogether/domain/RepositoryTest.java new file mode 100644 index 000000000..46d80f0c1 --- /dev/null +++ b/backend/src/test/java/com/votogether/domain/RepositoryTest.java @@ -0,0 +1,18 @@ +package com.votogether.domain; + +import com.votogether.config.JpaConfig; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import(JpaConfig.class) +@Target(value = ElementType.TYPE) +@Retention(value = RetentionPolicy.RUNTIME) +public @interface RepositoryTest { +} diff --git a/backend/src/test/java/com/votogether/domain/category/repository/CategoryRepositoryTest.java b/backend/src/test/java/com/votogether/domain/category/repository/CategoryRepositoryTest.java new file mode 100644 index 000000000..f09d07e26 --- /dev/null +++ b/backend/src/test/java/com/votogether/domain/category/repository/CategoryRepositoryTest.java @@ -0,0 +1,84 @@ +package com.votogether.domain.category.repository; + +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.RepositoryTest; +import com.votogether.domain.category.entity.Category; +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 org.springframework.dao.DataIntegrityViolationException; + +@RepositoryTest +class CategoryRepositoryTest { + + @Autowired + CategoryRepository categoryRepository; + + @Nested + @DisplayName("카테고리 저장") + class save { + + @Test + @DisplayName("카테고리를 저장한다.") + void save() { + // given + Category category = Category.builder() + .name("개발") + .build(); + + // when + categoryRepository.save(category); + + // then + assertThat(category.getId()).isNotNull(); + } + + @Test + @DisplayName("같은 이름의 카테고리를 저장할 시 에러가 발생한다.") + void saveButException() { + // given + Category category1 = Category.builder() + .name("개발") + .build(); + Category category2 = Category.builder() + .name("개발") + .build(); + categoryRepository.save(category1); + + // when & then + assertThatThrownBy(() -> categoryRepository.save(category2)) + .isInstanceOf(DataIntegrityViolationException.class); + } + + } + + @Test + @DisplayName("모든 카테고리를 조회한다.") + void getAllCategories() { + // given + Category category1 = Category.builder() + .name("개발") + .build(); + Category category2 = Category.builder() + .name("음식") + .build(); + + categoryRepository.save(category1); + categoryRepository.save(category2); + + // when + List categories = categoryRepository.findAll(); + + // then + assertAll( + () -> assertThat(categories).hasSize(2), + () -> assertThat(categories.get(0).getName()).isEqualTo("개발"), + () -> assertThat(categories.get(1).getName()).isEqualTo("음식")); + } + +}