From 68ae00c70ddcb94412800e1ed1f140931b664ecb Mon Sep 17 00:00:00 2001 From: JEON TAEHEON Date: Wed, 21 Aug 2024 10:56:02 +0900 Subject: [PATCH] =?UTF-8?q?=EB=AF=B8=EC=85=98=20=EC=A0=9C=EC=B6=9C=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=8A=A4=ED=83=80=EC=9D=BC?= =?UTF-8?q?=EB=A7=81=20=EC=A1=B0=EC=A0=95=20(issue=20#353)=20(#357)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * remove: front cd yml 파일 삭제 (#341) * 로그아웃 뮤테이션 생성 및 제출시 로직 개선 (issue #306) (#327) * feat: 로그아웃 관련 뮤테이션 생성 * refactor: useModal 리팩토링 * refactor: 불필요한 주석 제거 * fix: 미션 제출 후 새로고침 로직 개선 * refactor: 대쉬보드 프로필 레이아웃 수정 * refactor: description null값도 보낼수 있도록 수정 * refactor: 미션명 검증 섹션 추가 * fix: 린트 disable * refactor: 수정사항 반영 * 해시태그 버튼 및 필터링 구현 (issue #303) (#332) * feat: 미션 리스트 필터링 기능 구현 * refactor: 미션 호출 api의 url 분리 및 queryKey에 filter 추가 * fix: hashTagButton 충돌 해결 * feat: 전체 데이터 불러오는 'all'로 변경, HashTag 필터링 mock data 임시 구현 * feat: 솔루션 필터링 적용, useSolutions를 useSolutionSummaries로 변경 * design: 솔루션 리스트 디자인 수정 * design: 미션리스트 디자인 수정 * feat: api 배포에 따른 mock data 제거 * test: useMissions 필터링 기능 테스트 코드 추가 * design: 미션, 솔루션 리스트 렌더링 시 애니메이션 효과 적용 * refactor: 'all'을 상수로 변경 * refactor: useToggleHashTag 제거 및 해시태그 토글 상태 추가 * chore: 개발 환경과 배포환경에 따른 백엔드 API 경로 수정 (#344) * fix: 대시보드 렌더링 분기처리 오류 수정 (#346) * design: 미션 제출 페이지 중앙 정렬 --------- Co-authored-by: Minji <121149171+chosim-dvlpr@users.noreply.github.com> --- .github/workflows/front_cd.yml | 67 ------- frontend/src/apis/baseUrl.ts | 2 +- frontend/src/apis/missionAPI.ts | 26 ++- frontend/src/apis/paths.ts | 1 + frontend/src/apis/solutions.ts | 12 +- .../HashTagList/HashTagList.styled.ts | 8 + frontend/src/components/HashTagList/index.tsx | 33 ++++ frontend/src/components/Header/index.tsx | 16 +- frontend/src/components/Mission/index.tsx | 2 +- .../MissionDetail/MissionDetail.styled.ts | 1 - .../MissionList/MissionList.styled.ts | 15 +- .../components/MissionSubmit/SubmitButton.tsx | 16 +- .../HashTagButton/HashTagButton.styled.ts | 21 ++- .../components/common/HashTagButton/index.tsx | 18 +- frontend/src/constants/hashTags.ts | 3 + .../src/hooks/__tests__/useMissions.test.tsx | 10 ++ frontend/src/hooks/queries/keys.ts | 4 + frontend/src/hooks/useDescription.ts | 10 +- frontend/src/hooks/useHashTags.ts | 13 ++ frontend/src/hooks/useLogoutMutation.ts | 22 +++ frontend/src/hooks/useMissions.ts | 9 +- frontend/src/hooks/useModal.ts | 6 +- ...seSolutions.ts => useSolutionSummaries.ts} | 7 +- frontend/src/hooks/useSubmitSolution.ts | 7 +- .../src/hooks/useSubmitSolutionMutation.ts | 5 + frontend/src/mocks/SubmittedSolutions.json | 169 +++++++++++++++++- frontend/src/mocks/handlers.ts | 15 +- frontend/src/mocks/missions.json | 8 +- .../DashBoardPageLayout.styled.ts | 2 +- .../MissionListPage/MissionListPage.styled.ts | 23 ++- frontend/src/pages/MissionListPage/index.tsx | 18 +- frontend/src/pages/MissionSubmitPage.tsx | 12 +- .../SolutionListPage.styled.ts | 39 +++- frontend/src/pages/SolutionListPage/index.tsx | 21 ++- frontend/src/types/index.ts | 9 - frontend/src/types/mission.ts | 5 +- frontend/src/utils/extractMissionName.ts | 7 + 37 files changed, 498 insertions(+), 164 deletions(-) delete mode 100644 .github/workflows/front_cd.yml create mode 100644 frontend/src/components/HashTagList/HashTagList.styled.ts create mode 100644 frontend/src/components/HashTagList/index.tsx create mode 100644 frontend/src/constants/hashTags.ts create mode 100644 frontend/src/hooks/useHashTags.ts create mode 100644 frontend/src/hooks/useLogoutMutation.ts rename frontend/src/hooks/{useSolutions.ts => useSolutionSummaries.ts} (57%) create mode 100644 frontend/src/utils/extractMissionName.ts diff --git a/.github/workflows/front_cd.yml b/.github/workflows/front_cd.yml deleted file mode 100644 index 59376b6b..00000000 --- a/.github/workflows/front_cd.yml +++ /dev/null @@ -1,67 +0,0 @@ -name: Frontend CD - -on: - workflow_dispatch: - push: - branches: - - main - paths: - - frontend/** - -env: - ARTIFACT_DIRECTORY: ./frontend/dist - -jobs: - build: - name: Build Test - runs-on: ubuntu-latest - defaults: - run: - working-directory: ./frontend - - steps: - - uses: actions/checkout@v4 - - name: 🏗️ Install Node.js - uses: actions/setup-node@v4 - with: - node-version: 20.15.1 - - - name: 🏗️ Install Dependencies - run: npm install - - - name: 🏗️ Insert Environment Variables - run: | - touch .env - cat << EOF >> .env - ${{ secrets.FRONTEND_ENV }} - touch .env.sentry-build-plugin - cat << EOF >> .env.sentry-build-plugin - ${{ secrets.SENTRY_AUTH_ENV }} - - - name: 🏗️ Build - run: npm run build - - - name: 📤 Upload Artifact File - uses: actions/upload-artifact@v4 - with: - name: react-dist-files - path: ${{ env.ARTIFACT_DIRECTORY }} - - deploy: - name: 🚀 Web Deployment - needs: build - runs-on: [self-hosted, develup-front] - - steps: - - name: 📥 Download Artifact File - uses: actions/download-artifact@v4 - with: - name: react-dist-files - path: ./frontend/dist/ - merge-multiple: true - - - name: Move Build File - run: sudo cp -r ./frontend/dist/* /var/www/html - - - name: 🟢 Nginx Restart - run: sudo service nginx restart diff --git a/frontend/src/apis/baseUrl.ts b/frontend/src/apis/baseUrl.ts index ef2346b7..ac84fe58 100644 --- a/frontend/src/apis/baseUrl.ts +++ b/frontend/src/apis/baseUrl.ts @@ -1,4 +1,4 @@ export const BASE_URL = { - dev: 'https://api.devel-up.co.kr', + dev: 'https://dev.api.devel-up.co.kr', prod: 'https://api.devel-up.co.kr', } as const; diff --git a/frontend/src/apis/missionAPI.ts b/frontend/src/apis/missionAPI.ts index 6d0c1d87..7875317b 100644 --- a/frontend/src/apis/missionAPI.ts +++ b/frontend/src/apis/missionAPI.ts @@ -1,9 +1,15 @@ import { develupAPIClient } from './clients/develupClient'; import { PATH } from './paths'; -import type { Mission, SubmissionPayload, Submission, MissionWithDescription } from '@/types'; -// import type { MissionInProgress } from '@/types/mission'; +import type { + Mission, + MissionWithDescription, + SubmissionPayload, + Submission, + HashTag, +} from '@/types'; import MissionListInProgress from '@/mocks/missionInProgress.json'; import { populateMissionDescription } from './utils/populateMissionDescription'; +import { HASHTAGS } from '@/constants/hashTags'; interface getMissionByIdResponse { data: MissionWithDescription; @@ -13,8 +19,14 @@ interface getAllMissionResponse { data: Mission[]; } -export const getAllMissions = async (): Promise => { - const { data } = await develupAPIClient.get(PATH.missionList); +interface getHashTagsResponse { + data: HashTag[]; +} + +export const getMissions = async (filter: string = HASHTAGS.all): Promise => { + const { data } = await develupAPIClient.get(`${PATH.missionList}`, { + hashTag: filter, + }); return data; }; @@ -49,3 +61,9 @@ export const postSubmission = async (payload: SubmissionPayload) => { return data; }; + +export const getHashTags = async (): Promise => { + const { data } = await develupAPIClient.get(PATH.hashTags); + + return data; +}; diff --git a/frontend/src/apis/paths.ts b/frontend/src/apis/paths.ts index 460be23c..78ea2ec5 100644 --- a/frontend/src/apis/paths.ts +++ b/frontend/src/apis/paths.ts @@ -10,6 +10,7 @@ export const PATH = { submitSolution: '/solutions/submit', startSolution: '/solutions/start', logout: '/auth/logout', + hashTags: '/hash-tags', missionInProgress: '/missions/in-progress', }; diff --git a/frontend/src/apis/solutions.ts b/frontend/src/apis/solutions.ts index fc9b0553..63d3182a 100644 --- a/frontend/src/apis/solutions.ts +++ b/frontend/src/apis/solutions.ts @@ -1,7 +1,8 @@ import { develupAPIClient } from '@/apis/clients/develupClient'; import { PATH } from '@/apis/paths'; +import { HASHTAGS } from '@/constants/hashTags'; import SubmittedSolutions from '@/mocks/SubmittedSolutions.json'; -import type { HashTag } from '@/types/mission'; +import type { HashTag } from '@/types'; import type { Solution, SubmittedSolution } from '@/types/solution'; export interface SolutionSummary { @@ -17,8 +18,13 @@ interface GetSolutionSummariesResponse { data: SolutionSummary[]; } -export const getSolutionSummaries = async (): Promise => { - const { data } = await develupAPIClient.get(PATH.solutionSummaries); +export const getSolutionSummaries = async ( + filter: string = HASHTAGS.all, +): Promise => { + const { data } = await develupAPIClient.get( + `${PATH.solutionSummaries}`, + { hashTag: filter }, + ); return data; }; diff --git a/frontend/src/components/HashTagList/HashTagList.styled.ts b/frontend/src/components/HashTagList/HashTagList.styled.ts new file mode 100644 index 00000000..afa7e76b --- /dev/null +++ b/frontend/src/components/HashTagList/HashTagList.styled.ts @@ -0,0 +1,8 @@ +import styled from 'styled-components'; + +export const HashTagListContainer = styled.ul` + display: flex; + flex-direction: row; + gap: 1.5rem; + flex-wrap: wrap; +`; diff --git a/frontend/src/components/HashTagList/index.tsx b/frontend/src/components/HashTagList/index.tsx new file mode 100644 index 00000000..cfc079d5 --- /dev/null +++ b/frontend/src/components/HashTagList/index.tsx @@ -0,0 +1,33 @@ +import type { HashTag } from '@/types'; +import HashTagButton from '../common/HashTagButton'; +import * as S from './HashTagList.styled'; +import { HASHTAGS } from '@/constants/hashTags'; + +interface HashTagListProps { + hashTags: HashTag[]; + selectedHashTag: string; + setSelectedHashTag: (name: string) => void; +} + +export default function HashTagList({ + hashTags, + selectedHashTag, + setSelectedHashTag, +}: HashTagListProps) { + return ( + + {hashTags.map(({ id, name }) => { + const isSelected = name === selectedHashTag; + return ( + setSelectedHashTag(isSelected ? HASHTAGS.all : name)} + key={id} + > + {name} + + ); + })} + + ); +} diff --git a/frontend/src/components/Header/index.tsx b/frontend/src/components/Header/index.tsx index 28eae5cc..24871c8d 100644 --- a/frontend/src/components/Header/index.tsx +++ b/frontend/src/components/Header/index.tsx @@ -1,19 +1,19 @@ import { Link, useLocation } from 'react-router-dom'; import * as S from './Header.styled'; import { ROUTES } from '@/constants/routes'; +import NotiModal from './NotiModal'; import { BASE_URL } from '@/apis/baseUrl'; import { PATH } from '@/apis/paths'; import useUserInfo from '@/hooks/useUserInfo'; import HeaderMenu from './HeaderMenu'; -import { deleteLogout } from '@/apis/authAPI'; +import useLogoutMutation from '@/hooks/useLogoutMutation'; +import useModal from '@/hooks/useModal'; export default function Header() { const { pathname } = useLocation(); const { data: userInfo } = useUserInfo(); - - const handleUserLogout = async () => { - await deleteLogout(); - }; + const { handleUserLogout } = useLogoutMutation(); + const { isModalOpen, handleModalClose, handleToggleModal } = useModal(); return ( <> @@ -28,7 +28,10 @@ export default function Header() { - + {userInfo && } + {userInfo && ( + + )} {!userInfo ? ( 로그인 @@ -38,6 +41,7 @@ export default function Header() { )} + {isModalOpen && } ); diff --git a/frontend/src/components/Mission/index.tsx b/frontend/src/components/Mission/index.tsx index 6f7fd36f..1de67f31 100644 --- a/frontend/src/components/Mission/index.tsx +++ b/frontend/src/components/Mission/index.tsx @@ -1,7 +1,7 @@ import type { PropsWithChildren } from 'react'; import * as S from './Mission.styled'; import { Link } from 'react-router-dom'; -import type { HashTag } from '@/types/mission'; +import type { HashTag } from '@/types'; interface MissionProps extends PropsWithChildren { id: number; diff --git a/frontend/src/components/MissionDetail/MissionDetail.styled.ts b/frontend/src/components/MissionDetail/MissionDetail.styled.ts index dd23ab39..2562cf55 100644 --- a/frontend/src/components/MissionDetail/MissionDetail.styled.ts +++ b/frontend/src/components/MissionDetail/MissionDetail.styled.ts @@ -62,7 +62,6 @@ export const HashTagWrapper = styled.ul` export const MissionDetailButtonsContainer = styled.div` display: flex; - gap: 2rem; justify-content: space-between; align-items: center; `; diff --git a/frontend/src/components/MissionList/MissionList.styled.ts b/frontend/src/components/MissionList/MissionList.styled.ts index 0a53086c..c4e81ed8 100644 --- a/frontend/src/components/MissionList/MissionList.styled.ts +++ b/frontend/src/components/MissionList/MissionList.styled.ts @@ -1,4 +1,4 @@ -import styled from 'styled-components'; +import styled, { keyframes } from 'styled-components'; export const MissionListContainer = styled.div` display: flex; @@ -15,11 +15,24 @@ export const MissionListTitle = styled.h2` margin-bottom: 3rem; `; +const show = keyframes` + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +`; + export const MissionList = styled.div` display: flex; width: 100rem; column-gap: 5rem; row-gap: 3.6rem; + + animation: ${show} 0.5s; + transition: 0.5s; `; export const MissionItemContainer = styled.article` diff --git a/frontend/src/components/MissionSubmit/SubmitButton.tsx b/frontend/src/components/MissionSubmit/SubmitButton.tsx index e836fc6b..870411df 100644 --- a/frontend/src/components/MissionSubmit/SubmitButton.tsx +++ b/frontend/src/components/MissionSubmit/SubmitButton.tsx @@ -1,21 +1,9 @@ import * as S from './SubmitButton.styled'; -import type { ButtonHTMLAttributes } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { ROUTES } from '@/constants/routes'; - -interface SubmitButtonProps extends ButtonHTMLAttributes {} - -export default function SubmitButton({ ...props }: SubmitButtonProps) { - const navigate = useNavigate(); - const handleNavigateToSubmit = () => { - navigate(`${ROUTES.solutions}`); - }; +export default function SubmitButton() { return ( - - 제출 - + 제출 ); } diff --git a/frontend/src/components/common/HashTagButton/HashTagButton.styled.ts b/frontend/src/components/common/HashTagButton/HashTagButton.styled.ts index 6dc3bd26..51ce1ffe 100644 --- a/frontend/src/components/common/HashTagButton/HashTagButton.styled.ts +++ b/frontend/src/components/common/HashTagButton/HashTagButton.styled.ts @@ -1,14 +1,25 @@ import styled from 'styled-components'; -export const Button = styled.div` - background-color: ${(props) => props.theme.colors.primary50}; - color: ${(props) => props.theme.colors.black}; +interface ButtonProps { + $isSelected: boolean; +} + +export const Button = styled.button` + background-color: ${(props) => + props.$isSelected ? props.theme.colors.primary100 : props.theme.colors.primary50}; + color: var(--black-color); + transition: 0.2s; + + padding: 1rem 1.6rem; + border-radius: 2rem; display: flex; justify-content: center; align-items: center; ${(props) => props.theme.font.badge} - padding: 1rem 1.6rem; - border-radius: 2rem; + &:hover { + background-color: ${(props) => + props.$isSelected ? props.theme.colors.primary200 : props.theme.colors.primary100}; + } `; diff --git a/frontend/src/components/common/HashTagButton/index.tsx b/frontend/src/components/common/HashTagButton/index.tsx index 2dbcae0b..fbb0c6fd 100644 --- a/frontend/src/components/common/HashTagButton/index.tsx +++ b/frontend/src/components/common/HashTagButton/index.tsx @@ -1,6 +1,18 @@ -import type { PropsWithChildren } from 'react'; +import type { HTMLAttributes, PropsWithChildren } from 'react'; import * as S from './HashTagButton.styled'; -export default function HashTagButton({ children }: PropsWithChildren) { - return {children}; +interface HashTagButtonProps extends HTMLAttributes { + isSelected?: boolean; +} + +export default function HashTagButton({ + isSelected = false, + children, + ...props +}: PropsWithChildren) { + return ( + + {children} + + ); } diff --git a/frontend/src/constants/hashTags.ts b/frontend/src/constants/hashTags.ts new file mode 100644 index 00000000..fdd1fab6 --- /dev/null +++ b/frontend/src/constants/hashTags.ts @@ -0,0 +1,3 @@ +export const HASHTAGS = { + all: 'all', +}; diff --git a/frontend/src/hooks/__tests__/useMissions.test.tsx b/frontend/src/hooks/__tests__/useMissions.test.tsx index 2e4c1ffc..c71c2c98 100644 --- a/frontend/src/hooks/__tests__/useMissions.test.tsx +++ b/frontend/src/hooks/__tests__/useMissions.test.tsx @@ -18,4 +18,14 @@ describe('useMissions', () => { expect(result.current.data).toEqual(missions); }); + + it('필터링을 할 수 있다.', async () => { + const filter = missions[0].hashTags[0].name; + const filteredMissions = missions.filter((mission) => mission.hashTags[0].name === filter); + const { result } = renderHook(() => useMissions(filter), { wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data).toEqual(filteredMissions); + }); }); diff --git a/frontend/src/hooks/queries/keys.ts b/frontend/src/hooks/queries/keys.ts index b7fa5436..b5cef85e 100644 --- a/frontend/src/hooks/queries/keys.ts +++ b/frontend/src/hooks/queries/keys.ts @@ -20,6 +20,10 @@ export const solutionKeys = { submitted: ['submitted solutions'], }; +export const hashTagsKeys = { + hashTags: ['hashTags'], +}; + export const userKeys = { info: ['userInfo'], }; diff --git a/frontend/src/hooks/useDescription.ts b/frontend/src/hooks/useDescription.ts index c1a57409..c9660c46 100644 --- a/frontend/src/hooks/useDescription.ts +++ b/frontend/src/hooks/useDescription.ts @@ -9,10 +9,12 @@ const useDescription = () => { const [isDescriptionError, setIsDescriptionError] = useState(false); //TODO 코멘트 길이에 관한 최대 글자수가 정해져야할거 같아서 임시로 100자로 지정해둡니다. @버건디 - const isValidDescription = validateMaxLength({ - value: description ?? '', - maxLength: MAX_COMMENT_LENGTH, - }); + const isValidDescription = description + ? validateMaxLength({ + value: description, + maxLength: MAX_COMMENT_LENGTH, + }) + : true; const handleDescription = (e: ChangeEvent) => { const value = e.target.value; diff --git a/frontend/src/hooks/useHashTags.ts b/frontend/src/hooks/useHashTags.ts new file mode 100644 index 00000000..0c6aa6ce --- /dev/null +++ b/frontend/src/hooks/useHashTags.ts @@ -0,0 +1,13 @@ +import { getHashTags } from '@/apis/missionAPI'; +import type { HashTag } from '@/types'; +import { useSuspenseQuery } from '@tanstack/react-query'; +import { hashTagsKeys } from './queries/keys'; + +const useHashTags = () => { + return useSuspenseQuery({ + queryKey: hashTagsKeys.hashTags, + queryFn: () => getHashTags(), + }); +}; + +export default useHashTags; diff --git a/frontend/src/hooks/useLogoutMutation.ts b/frontend/src/hooks/useLogoutMutation.ts new file mode 100644 index 00000000..87a044ce --- /dev/null +++ b/frontend/src/hooks/useLogoutMutation.ts @@ -0,0 +1,22 @@ +import { useMutation } from '@tanstack/react-query'; +import { deleteLogout } from '@/apis/authAPI'; + +const useLogoutMutation = () => { + const { mutate: userLogoutMutation } = useMutation({ + mutationFn: deleteLogout, + onSuccess: () => { + window.location.reload(); + }, + onError: (error: Error) => { + console.error(error.message); + }, + }); + + const handleUserLogout = () => { + userLogoutMutation(); + }; + + return { handleUserLogout }; +}; + +export default useLogoutMutation; diff --git a/frontend/src/hooks/useMissions.ts b/frontend/src/hooks/useMissions.ts index 2c71b60d..e8868413 100644 --- a/frontend/src/hooks/useMissions.ts +++ b/frontend/src/hooks/useMissions.ts @@ -1,12 +1,13 @@ import { useSuspenseQuery } from '@tanstack/react-query'; import type { Mission } from '@/types'; -import { getAllMissions } from '@/apis/missionAPI'; +import { getMissions } from '@/apis/missionAPI'; import { missionKeys } from './queries/keys'; +import { HASHTAGS } from '@/constants/hashTags'; -const useMissions = () => { +const useMissions = (filter: string = HASHTAGS.all) => { return useSuspenseQuery({ - queryKey: missionKeys.all, - queryFn: getAllMissions, + queryKey: [...missionKeys.all, filter], + queryFn: () => getMissions(filter), }); }; diff --git a/frontend/src/hooks/useModal.ts b/frontend/src/hooks/useModal.ts index 6a83eb34..16ef71a3 100644 --- a/frontend/src/hooks/useModal.ts +++ b/frontend/src/hooks/useModal.ts @@ -11,7 +11,11 @@ const useModal = () => { setIsModalOpen(false); }; - return { isModalOpen, handleModalClose, handleModalOpen }; + const handleToggleModal = () => { + setIsModalOpen((prev) => !prev); + }; + + return { isModalOpen, handleModalClose, handleModalOpen, handleToggleModal }; }; export default useModal; diff --git a/frontend/src/hooks/useSolutions.ts b/frontend/src/hooks/useSolutionSummaries.ts similarity index 57% rename from frontend/src/hooks/useSolutions.ts rename to frontend/src/hooks/useSolutionSummaries.ts index fe871973..d4b042aa 100644 --- a/frontend/src/hooks/useSolutions.ts +++ b/frontend/src/hooks/useSolutionSummaries.ts @@ -1,11 +1,12 @@ import { useSuspenseQuery } from '@tanstack/react-query'; import { getSolutionSummaries, type SolutionSummary } from '@/apis/solutions'; import { solutionKeys } from './queries/keys'; +import { HASHTAGS } from '@/constants/hashTags'; -const useSolutionSummaries = () => { +const useSolutionSummaries = (filter: string = HASHTAGS.all) => { return useSuspenseQuery({ - queryKey: solutionKeys.summaries, - queryFn: getSolutionSummaries, + queryKey: [...solutionKeys.all, filter], + queryFn: () => getSolutionSummaries(filter), }); }; diff --git a/frontend/src/hooks/useSubmitSolution.ts b/frontend/src/hooks/useSubmitSolution.ts index 7a8e18ea..fbc95803 100644 --- a/frontend/src/hooks/useSubmitSolution.ts +++ b/frontend/src/hooks/useSubmitSolution.ts @@ -4,13 +4,17 @@ import useSubmitSolutionMutation from './useSubmitSolutionMutation'; import useModal from './useModal'; import type { FormEvent } from 'react'; import useSolutionTitle from './useSolutionTitle'; +import extractMissionName from '@/utils/extractMissionName'; interface UseSubmitSolutionParams { missionId: number; + missionName: string; } -const useSubmitSolution = ({ missionId }: UseSubmitSolutionParams) => { +const useSubmitSolution = ({ missionId, missionName }: UseSubmitSolutionParams) => { const { url, handleUrl, isValidUrl, isUrlError, setIsUrlError } = useUrl(); + const isMatchedMissionName = missionName === extractMissionName(url); + const { description, handleDescription, @@ -65,6 +69,7 @@ const useSubmitSolution = ({ missionId }: UseSubmitSolutionParams) => { isUrlError, isDescriptionError, isSolutionTitleError, + isMatchedMissionName, }; }; diff --git a/frontend/src/hooks/useSubmitSolutionMutation.ts b/frontend/src/hooks/useSubmitSolutionMutation.ts index 6f497ffb..ac5a6b82 100644 --- a/frontend/src/hooks/useSubmitSolutionMutation.ts +++ b/frontend/src/hooks/useSubmitSolutionMutation.ts @@ -2,6 +2,8 @@ import { postSolutionSubmit } from '@/apis/solutions'; import { queryClient } from '@/index'; import { missionKeys, solutionKeys } from './queries/keys'; import useSingleRequestMutation from './useSingleRequestMutation'; +import { ROUTES } from '../constants/routes'; +import { useNavigate } from 'react-router-dom'; interface UseSubmissionMutationParams { onSuccessCallback: () => void; @@ -14,12 +16,15 @@ const useSubmitSolutionMutation = ({ onSuccessCallback, missionId, }: UseSubmissionMutationParams) => { + const navigate = useNavigate(); + const { mutate: submitSolutionMutation, isPending } = useSingleRequestMutation({ queryFn: postSolutionSubmit, onSuccess: () => { onSuccessCallback(); queryClient.invalidateQueries({ queryKey: missionKeys.detail(missionId) }); queryClient.invalidateQueries({ queryKey: solutionKeys.summaries }); + navigate(`${ROUTES.solutions}`); }, onError: (error: Error) => { console.error(error.message); diff --git a/frontend/src/mocks/SubmittedSolutions.json b/frontend/src/mocks/SubmittedSolutions.json index 4a466f45..8fdd8b39 100644 --- a/frontend/src/mocks/SubmittedSolutions.json +++ b/frontend/src/mocks/SubmittedSolutions.json @@ -1,17 +1,170 @@ [ { - "id": 1, - "title": "미션1", - "thumbnail": "https://www.arimetrics.com/wp-content/uploads/2020/01/mockup-1.png" + "id": 6, + "title": "아톰 미션 제출합니다.", + "thumbnail": "https://www.arimetrics.com/wp-content/uploads/2020/01/mockup-1.png", + "description": "안녕하세요. 잘 부탁 드립니다.", + "hashTags": [ + { + "id": 1, + "name": "JAVA" + }, + { + "id": 2, + "name": "객체지향" + }, + { + "id": 3, + "name": "TDD" + }, + { + "id": 4, + "name": "클린코드" + }, + { + "id": 5, + "name": "레벨1" + } + ] }, { - "id": 2, - "title": "미션2", - "thumbnail": "https://www.arimetrics.com/wp-content/uploads/2020/01/mockup-1.png" + "id": 5, + "title": "릴리 미션 제출합니다.", + "thumbnail": "https://www.arimetrics.com/wp-content/uploads/2020/01/mockup-1.png", + "description": "안녕하세요. 잘 부탁 드립니다.", + "hashTags": [ + { + "id": 1, + "name": "JAVA" + }, + { + "id": 2, + "name": "객체지향" + }, + { + "id": 3, + "name": "TDD" + }, + { + "id": 4, + "name": "클린코드" + }, + { + "id": 5, + "name": "레벨1" + } + ] + }, + { + "id": 4, + "title": "아톰 미션 제출합니다.", + "thumbnail": "https://www.arimetrics.com/wp-content/uploads/2020/01/mockup-1.png", + "description": "안녕하세요. 잘 부탁 드립니다.", + "hashTags": [ + { + "id": 1, + "name": "JAVA" + }, + { + "id": 2, + "name": "객체지향" + }, + { + "id": 3, + "name": "TDD" + }, + { + "id": 4, + "name": "클린코드" + }, + { + "id": 5, + "name": "레벨1" + } + ] }, { "id": 3, - "title": "미션3", - "thumbnail": "https://www.arimetrics.com/wp-content/uploads/2020/01/mockup-1.png" + "title": "라이언 미션 제출합니다.", + "thumbnail": "https://www.arimetrics.com/wp-content/uploads/2020/01/mockup-1.png", + "description": "안녕하세요. 잘 부탁 드립니다.", + "hashTags": [ + { + "id": 1, + "name": "JAVA" + }, + { + "id": 2, + "name": "객체지향" + }, + { + "id": 3, + "name": "TDD" + }, + { + "id": 4, + "name": "클린코드" + }, + { + "id": 5, + "name": "레벨1" + } + ] + }, + { + "id": 2, + "title": "아톰 미션 제출합니다.", + "thumbnail": "https://www.arimetrics.com/wp-content/uploads/2020/01/mockup-1.png", + "description": "안녕하세요. 잘 부탁 드립니다.", + "hashTags": [ + { + "id": 1, + "name": "JAVA" + }, + { + "id": 2, + "name": "객체지향" + }, + { + "id": 3, + "name": "TDD" + }, + { + "id": 4, + "name": "클린코드" + }, + { + "id": 5, + "name": "레벨1" + } + ] + }, + { + "id": 1, + "title": "릴리 미션 제출합니다.", + "thumbnail": "https://www.arimetrics.com/wp-content/uploads/2020/01/mockup-1.png", + "description": "안녕하세요. 잘 부탁 드립니다.", + "hashTags": [ + { + "id": 1, + "name": "JAVA" + }, + { + "id": 2, + "name": "객체지향" + }, + { + "id": 3, + "name": "TDD" + }, + { + "id": 4, + "name": "클린코드" + }, + { + "id": 5, + "name": "레벨1" + } + ] } ] diff --git a/frontend/src/mocks/handlers.ts b/frontend/src/mocks/handlers.ts index e4ad9383..3b8cd227 100644 --- a/frontend/src/mocks/handlers.ts +++ b/frontend/src/mocks/handlers.ts @@ -3,12 +3,21 @@ import { BASE_URL } from '@/apis/baseUrl'; import { PATH } from '@/apis/paths'; import missions from './missions.json'; import submittedSolutions from './SubmittedSolutions.json'; +import { HASHTAGS } from '@/constants/hashTags'; export const handlers = [ - http.get(`${BASE_URL.dev}${PATH.missionList}`, () => { - return HttpResponse.json({ data: missions }); + http.get(`${BASE_URL.dev}${PATH.missionList}`, ({ request }) => { + const url = new URL(request.url); + const hashTag = url.searchParams.get('hashTag'); + if (hashTag === HASHTAGS.all) { + return HttpResponse.json({ data: missions }); + } + + const filteredMissions = missions.filter((mission) => mission.hashTags[0].name === hashTag); + + return HttpResponse.json({ data: filteredMissions }); }), - + http.get(`${BASE_URL.dev}${PATH.missionList}/:id`, ({ request }) => { const url = new URL(request.url); const id = Number(url.pathname.split('/').pop()); diff --git a/frontend/src/mocks/missions.json b/frontend/src/mocks/missions.json index 0b50e9d5..6f19e9d1 100644 --- a/frontend/src/mocks/missions.json +++ b/frontend/src/mocks/missions.json @@ -25,8 +25,8 @@ "url": "https://github.com/develup-mission/java-word-puzzle", "hashTags": [ { - "id": 1, - "name": "111" + "id": 2, + "name": "222" } ] }, @@ -40,8 +40,8 @@ "url": "https://github.com/develup-mission/java-guessing-number", "hashTags": [ { - "id": 1, - "name": "111" + "id": 3, + "name": "333" } ] } diff --git a/frontend/src/pages/DashboardPage/DashBoardPageLayout/DashBoardPageLayout.styled.ts b/frontend/src/pages/DashboardPage/DashBoardPageLayout/DashBoardPageLayout.styled.ts index ac79d37f..eb208d08 100644 --- a/frontend/src/pages/DashboardPage/DashBoardPageLayout/DashBoardPageLayout.styled.ts +++ b/frontend/src/pages/DashboardPage/DashBoardPageLayout/DashBoardPageLayout.styled.ts @@ -33,12 +33,12 @@ export const ProfileImageWrapper = styled.div` border-radius: 50%; border: 1px solid ${(props) => props.theme.colors.grey400}; overflow: hidden; - padding: 1rem; `; export const ProfileImage = styled.img` width: 100%; height: 100%; + border-radius: 50%; `; export const ProfileName = styled.span` diff --git a/frontend/src/pages/MissionListPage/MissionListPage.styled.ts b/frontend/src/pages/MissionListPage/MissionListPage.styled.ts index 53446bce..e3034333 100644 --- a/frontend/src/pages/MissionListPage/MissionListPage.styled.ts +++ b/frontend/src/pages/MissionListPage/MissionListPage.styled.ts @@ -3,15 +3,28 @@ import styled from 'styled-components'; export const MissionListPageContainer = styled.div` display: flex; flex-direction: column; - gap: 5rem; - margin: 0 auto; - margin-bottom: 10rem; - padding: 3.5rem 0; + gap: 3rem; - width: fit-content; + margin: 5rem auto; + width: 100%; + max-width: 100rem; `; export const MissionListTitle = styled.h2` margin-bottom: 3.5rem; ${(props) => props.theme.font.heading1}; `; + +export const TitleWrapper = styled.div` + display: flex; + flex-direction: column; + gap: 1rem; +`; + +export const Subtitle = styled.p` + font-size: 1.6rem; + font-weight: 500; + font-family: inherit; + + color: var(--grey-500); +`; diff --git a/frontend/src/pages/MissionListPage/index.tsx b/frontend/src/pages/MissionListPage/index.tsx index 5ad606b3..e84e5b83 100644 --- a/frontend/src/pages/MissionListPage/index.tsx +++ b/frontend/src/pages/MissionListPage/index.tsx @@ -1,13 +1,27 @@ import MissionList from '@/components/MissionList'; import * as S from './MissionListPage.styled'; import useMissions from '@/hooks/useMissions'; +import useHashTags from '@/hooks/useHashTags'; +import HashTagList from '@/components/HashTagList'; +import { useState } from 'react'; +import { HASHTAGS } from '@/constants/hashTags'; export default function MissionListPage() { - const { data: allMissions } = useMissions(); + const [selectedHashTag, setSelectedHashTag] = useState(HASHTAGS.all); + const { data: allMissions } = useMissions(selectedHashTag); + const { data: allHashTags } = useHashTags(); return ( - 지금 참여할 수 있는 미션 + + 🎯 지금 참여할 수 있는 미션 + 미션에 참여하고 의견을 주고받을 수 있어요! + + ); diff --git a/frontend/src/pages/MissionSubmitPage.tsx b/frontend/src/pages/MissionSubmitPage.tsx index 41ed932c..2e83c97e 100644 --- a/frontend/src/pages/MissionSubmitPage.tsx +++ b/frontend/src/pages/MissionSubmitPage.tsx @@ -16,6 +16,8 @@ export default function MissionSubmitPage() { const { id } = useParams(); const missionId = Number(id) || 0; const { data: mission } = useMission(missionId); + const missionName = new URL(mission.url).pathname.split('/').pop() ?? ''; + const { solutionTitle, url, @@ -29,7 +31,8 @@ export default function MissionSubmitPage() { isUrlError, isDescriptionError, isSolutionTitleError, - } = useSubmitSolution({ missionId }); + isMatchedMissionName, + } = useSubmitSolution({ missionId, missionName }); return ( @@ -46,7 +49,12 @@ export default function MissionSubmitPage() { onChange={handleSolutionTitle} danger={isSolutionTitleError} /> - + props.theme.font.heading1} `; +export const Subtitle = styled.p` + font-size: 1.6rem; + font-weight: 500; + font-family: inherit; + + color: var(--grey-500); +`; + +export const TitleWrapper = styled.div` + display: flex; + flex-direction: column; + gap: 1rem; +`; + export const SolutionListPageContainer = styled.div` - margin: 0 auto; - width: fit-content; - padding: 3.5rem 0; + width: 100%; + max-width: 100rem; + margin: 5rem auto; + + display: flex; + flex-direction: column; + gap: 3rem; +`; + +const show = keyframes` + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } `; export const SolutionList = styled.div` @@ -18,4 +46,7 @@ export const SolutionList = styled.div` column-gap: 5rem; row-gap: 3.6rem; flex-wrap: wrap; + + animation: ${show} 0.5s; + transition: 0.5s; `; diff --git a/frontend/src/pages/SolutionListPage/index.tsx b/frontend/src/pages/SolutionListPage/index.tsx index 36ffcfa0..4441e3de 100644 --- a/frontend/src/pages/SolutionListPage/index.tsx +++ b/frontend/src/pages/SolutionListPage/index.tsx @@ -1,15 +1,30 @@ import * as S from './SolutionListPage.styled'; -import useSolutionSummaries from '@/hooks/useSolutions'; +import useSolutionSummaries from '@/hooks/useSolutionSummaries'; import InfoCard from '@/components/common/InfoCard'; +import HashTagList from '@/components/HashTagList'; +import useHashTags from '@/hooks/useHashTags'; import { Link } from 'react-router-dom'; import { ROUTES } from '@/constants/routes'; +import { HASHTAGS } from '@/constants/hashTags'; +import { useState } from 'react'; export default function SolutionListPage() { - const { data: solutionSummaries } = useSolutionSummaries(); + const [selectedHashTag, setSelectedHashTag] = useState(HASHTAGS.all); + + const { data: solutionSummaries } = useSolutionSummaries(selectedHashTag); + const { data: allHashTags } = useHashTags(); return ( - 💡 Solutions + + 💡 다른 사람의 풀이 + 다른 사람이 푼 풀이도 확인해보세요! + + {solutionSummaries.map(({ id, thumbnail, title, description, hashTags }) => ( diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 4b501f14..603fcb51 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -27,15 +27,6 @@ export interface MissionWithDescription extends Mission { description: string; } -export interface Mission { - id: number; - title: string; - language: string; - descriptionUrl: string; - thumbnail: string; - url: string; - isStarted?: boolean; -} // postSubmission에 관련된 타입 선언 export interface SubmissionPayload { missionId: number; diff --git a/frontend/src/types/mission.ts b/frontend/src/types/mission.ts index 25763dbd..ee92dfdb 100644 --- a/frontend/src/types/mission.ts +++ b/frontend/src/types/mission.ts @@ -1,7 +1,4 @@ -export interface HashTag { - id: number; - name: string; -} +import type { HashTag } from '.'; export interface MissionInProgress { id: number; diff --git a/frontend/src/utils/extractMissionName.ts b/frontend/src/utils/extractMissionName.ts new file mode 100644 index 00000000..3b2e3445 --- /dev/null +++ b/frontend/src/utils/extractMissionName.ts @@ -0,0 +1,7 @@ +/*eslint-disable no-useless-escape */ +const extractMissionName = (url: string) => { + const match = url.match(/^https:\/\/github\.com\/[^\/]+\/([^\/]+)\/pull\/\d+$/); + return match ? match[1] : ''; +}; + +export default extractMissionName;