-
Notifications
You must be signed in to change notification settings - Fork 2
[feat] 스터디 목록 페이지 추가 #63
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
f3ca51b
60c582f
b267b33
d510874
567890b
7404d95
191e527
b63a65d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export const BASE_URL = 'https://mock.chongchong.com'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| import api from '../../client'; | ||
| import { STUDY_URLS } from './urls'; | ||
| import { Study } from './types'; | ||
|
|
||
| export async function fetchStudies() { | ||
| try { | ||
| const response = await api.get(STUDY_URLS.list); | ||
| return await response.json<{ studies: Study[] }>(); | ||
| } catch { | ||
| throw new Error('스터디 목록을 불러오는데 실패했습니다.'); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| import { useSuspenseQuery } from '@tanstack/react-query'; | ||
| import studyQueries from '../queries'; | ||
| import StudyList from './StudyList'; | ||
| import EmptyState from '../../../shared/ui/EmptyState'; | ||
| import { typography } from '../../../styles/global'; | ||
| import { tokens } from '../../../styles/global'; | ||
|
|
||
| export default function MyStudies() { | ||
| const { data: studies } = useSuspenseQuery({ | ||
| ...studyQueries.list(), | ||
| select: (data) => data.studies, | ||
| }); | ||
|
|
||
| return ( | ||
| <section aria-labelledby="my-studies-heading"> | ||
| <h2 id="my-studies-heading" css={typography.subtitle}> | ||
| 내 스터디 | ||
| </h2> | ||
| {studies.length === 0 ? ( | ||
| <div css={{ margin: `${tokens.spacing[8]} 0` }}> | ||
| <EmptyState message="아직 스터디가 없어요" /> | ||
| </div> | ||
| ) : ( | ||
| <StudyList studies={studies} /> | ||
| )} | ||
| </section> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| import List from '../../../shared/ui/List'; | ||
| import ContentCard from '../../../shared/ui/card/ContentCard'; | ||
| import Badge from '../../../shared/ui/Badge'; | ||
| import noticeIcon from '../../../shared/assets/notice.svg'; | ||
| import assignIcon from '../../../shared/assets/assign.svg'; | ||
| import rightArrowIcon from '../../../shared/assets/right-arrow.svg'; | ||
| import { Study } from '../types'; | ||
|
|
||
| export default function StudyList({ studies }: { studies: Study[] }) { | ||
| return ( | ||
| <List> | ||
| {studies.map((study) => { | ||
| return ( | ||
| <a href=""> | ||
| <List.Item key={study.id}> | ||
| <ContentCard> | ||
| <ContentCard.Badges> | ||
| <Badge variant="BrandSolid" size="Small"> | ||
| {study.role === 'STUDY_LEADER' ? '스터디 리드' : '스터디원'} | ||
| </Badge> | ||
| </ContentCard.Badges> | ||
|
|
||
| <ContentCard.TitleRow> | ||
| <ContentCard.Title>{study.title}</ContentCard.Title> | ||
| <ContentCard.Trailing> | ||
| <img src={rightArrowIcon} alt="" css={{ width: '20px', height: '20px' }} /> | ||
| </ContentCard.Trailing> | ||
| </ContentCard.TitleRow> | ||
|
|
||
| <ContentCard.Description>{study.description}</ContentCard.Description> | ||
|
|
||
| <ContentCard.Footer> | ||
| <ContentCard.Badge variant="NeutralSolid" size="Small"> | ||
| <img src={noticeIcon} alt="" css={{ width: '12px', height: '12px' }} /> | ||
| 공지 {study.noticeCount} | ||
| </ContentCard.Badge> | ||
| <ContentCard.Badge variant="NeutralSolid" size="Small"> | ||
| <img src={assignIcon} alt="" css={{ width: '12px', height: '12px' }} /> | ||
| 과제 {study.assignmentCount} | ||
| </ContentCard.Badge> | ||
| </ContentCard.Footer> | ||
| </ContentCard> | ||
| </List.Item> | ||
| </a> | ||
| ); | ||
| })} | ||
| </List> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| import { render, screen } from '@testing-library/react'; | ||
| import { createWrapper, mockResponse } from '../../../test/render'; | ||
| import { http, HttpResponse } from 'msw'; | ||
| import { server } from '../../../mocks/msw-node'; | ||
| import MyStudiesPage from './MyStudiesPage'; | ||
| import { BASE_URL } from '../../../../config'; | ||
| import { STUDY_URLS } from '../urls'; | ||
| import { Study } from '../types'; | ||
|
|
||
| const studies: Study[] = [ | ||
| { | ||
| id: '1', | ||
| role: 'STUDY_LEADER', | ||
| title: '점심메뉴 스터디', | ||
| description: '매주 진행하는 점심메뉴 정하기', | ||
| memberCount: 3, | ||
| noticeCount: 2, | ||
| assignmentCount: 2, | ||
| }, | ||
| { | ||
| id: '2', | ||
| role: 'SOME', | ||
| title: '저녁메뉴 스터디', | ||
| description: '매주 진행하는 저녁메뉴 정하기', | ||
| memberCount: 5, | ||
| noticeCount: 1, | ||
| assignmentCount: 0, | ||
| }, | ||
| ]; | ||
|
|
||
| const STUDIES_URL = `${BASE_URL}${STUDY_URLS.list}`; | ||
|
|
||
| test('응답으로 받은 스터디들을 렌더링 한다', async () => { | ||
| mockResponse(STUDIES_URL, studies); | ||
|
|
||
| render(<MyStudiesPage />, { wrapper: createWrapper() }); | ||
|
|
||
| expect(await screen.findAllByRole('listitem')).toHaveLength(studies.length); | ||
|
|
||
| expect(screen.getByText('점심메뉴 스터디')).toBeInTheDocument(); | ||
| expect(screen.getByText('저녁메뉴 스터디')).toBeInTheDocument(); | ||
| }); | ||
|
|
||
| test('스터디 역할에 따라 다른 뱃지를 렌더링 한다', async () => { | ||
| mockResponse(STUDIES_URL, studies); | ||
|
|
||
| render(<MyStudiesPage />, { wrapper: createWrapper() }); | ||
|
|
||
| expect(await screen.findByText('스터디 리드')).toBeInTheDocument(); | ||
| expect(screen.getByText('스터디원')).toBeInTheDocument(); | ||
| }); | ||
|
|
||
| test('스터디 목록 요청이 실패하면 에러 메시지가 렌더링 한다', async () => { | ||
| server.use(http.get(STUDIES_URL, () => new HttpResponse(null, { status: 500 }))); | ||
|
|
||
| render(<MyStudiesPage />, { wrapper: createWrapper() }); | ||
|
|
||
| expect(await screen.findByText('스터디 목록을 불러오는데 실패했습니다.')).toBeInTheDocument(); | ||
| }); | ||
|
|
||
| test('참여 중인 스터디가 없으면 비어있는 상태를 렌더링 한다', async () => { | ||
| mockResponse(STUDIES_URL, []); | ||
|
|
||
| render(<MyStudiesPage />, { wrapper: createWrapper() }); | ||
|
|
||
| expect(await screen.findByRole('heading', { name: '내 스터디' })).toBeInTheDocument(); | ||
| expect(screen.queryAllByRole('listitem')).toHaveLength(0); | ||
| }); |
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 개인적으로 페이지파일을 어디에 두는게 좋을지 고민하다가 그냥 studies 루트에 뒀습니다.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 저라면 페이지가 여러 개인 경우가 생기면 pages라는 폴더를 만들어서 그 안에 페이지 파일을 위치시킬 것 같아요.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 좋습니다. 의견을 들을 수 있어서 좋네요. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| import { Suspense, CSSProperties } from 'react'; | ||
| import { ErrorBoundary, getErrorMessage } from 'react-error-boundary'; | ||
| import TopHeader from '../../../shared/ui/TopHeader'; | ||
| import logo from '../../../shared/assets/icons/header-icon.svg'; | ||
| import MyStudies from '../components/MyStudies'; | ||
| import Main from '../../../shared/ui/Main'; | ||
| import footerIcon from '../../../shared/assets/icons/footer-icon.svg'; | ||
| import { tokens } from '../../../styles/global'; | ||
| import { typography } from '../../../styles/global'; | ||
| import Button from '../../../shared/ui/Button'; | ||
|
|
||
| const pageStyle = { | ||
| display: 'flex', | ||
| flexDirection: 'column', | ||
| minHeight: '100dvh', | ||
| background: tokens.bg.default, | ||
| } satisfies CSSProperties; | ||
|
|
||
| const actionsStyle = { | ||
| display: 'flex', | ||
| gap: tokens.spacing[3], | ||
| flexDirection: 'column', | ||
| margin: `${tokens.spacing[3]} 0`, | ||
| } satisfies CSSProperties; | ||
|
|
||
| const footerBannerStyle = { | ||
| display: 'flex', | ||
| alignItems: 'center', | ||
| justifyContent: 'center', | ||
| gap: tokens.spacing[1], | ||
| } satisfies CSSProperties; | ||
|
|
||
| export default function StudyListPage() { | ||
| return ( | ||
| <div css={pageStyle}> | ||
| <TopHeader | ||
| middle={ | ||
| <div> | ||
| <img css={{ width: '40px', height: '40px' }} src={logo} alt="" /> | ||
| </div> | ||
| } | ||
| right={<a href="#">My</a>} | ||
| /> | ||
| <Main> | ||
| <ErrorBoundary fallbackRender={({ error }) => <p>{getErrorMessage(error)}</p>}> | ||
| <Suspense fallback={<p>loading ...</p>}> | ||
| <MyStudies /> | ||
| </Suspense> | ||
| </ErrorBoundary> | ||
| <div css={actionsStyle}> | ||
| {/* 진짜 link로 전환하는게 접근성 더 좋음, 스크린 리더의 링크에 안잡힘 */} | ||
| <Button role="link" variant="brandSolid" size="large"> | ||
| 스터디 만들기 | ||
| </Button> | ||
| <Button role="link" variant="neutralOutline" size="large"> | ||
| 스터디 참여하기 | ||
| </Button> | ||
| </div> | ||
| <aside css={footerBannerStyle}> | ||
| <img src={footerIcon} css={{ height: '52px', width: '52px' }} alt="" /> | ||
| <div css={{ display: 'flex', gap: tokens.spacing[1], flexDirection: 'column' }}> | ||
| <p css={typography.paragraph}>리마인드는 총총이 보낼게요.</p> | ||
| <p css={{ ...typography.footnote, color: tokens.color.optionSubFontColor55 }}> | ||
| 정해둔 시각에 미확인자, 미제출자에게 알림을 보내요 | ||
| </p> | ||
| </div> | ||
| </aside> | ||
| </Main> | ||
| </div> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| import { queryOptions } from '@tanstack/react-query'; | ||
| import { fetchStudies } from './api'; | ||
|
|
||
| const studyQueries = { | ||
| all: () => ['studies'], | ||
| lists: () => [...studyQueries.all(), 'list'], | ||
| list: () => | ||
| queryOptions({ | ||
| queryKey: [...studyQueries.lists()], | ||
| queryFn: () => fetchStudies(), | ||
| }), | ||
| }; | ||
|
|
||
| export default studyQueries; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| import { RouteObject } from 'react-router'; | ||
| import MyStudiesPage from './pages/MyStudiesPage'; | ||
|
|
||
| export const routes: RouteObject[] = [ | ||
| { | ||
| path: '/studies', | ||
| element: <MyStudiesPage />, | ||
| }, | ||
| ]; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| type Role = 'STUDY_LEADER' | 'SOME'; | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 지금 당장은 SOME ... |
||
|
|
||
| export interface Study { | ||
| id: string; | ||
| role: Role; | ||
| title: string; | ||
| description: string; | ||
| memberCount: number; | ||
| noticeCount: number; | ||
| assignmentCount: number; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| export const STUDY_URLS = { | ||
| list: '/studies/me', | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
인라인 스타일과 외부에서 스타일을 정의해서 가져다 쓰는 방식중 기준이 있으신가요 ?
저는 지금당장은 변할 가능성이 낮고 적은 속성일때는 인라인에 추가하는거 같습니다.
반대로 속성이 많고 변할 가능성이 존재하는 경우에는 따로 정의하는거 같아요. (사실 변할 가능성보다는 속성이 많게되면 자연스럽게 분리되는거 같습니다.)
인라인으로 적용했을때는 타입을 적용하기 어려울거 같습니다.
뭐
<img src={rightArrowIcon} alt="" css={{ width: '20px', height: '20px' } satisfies CSSProperties} />처럼 적용은 할 수 있겠지만 이런식으로 인라인에 정의하는 방식이 코드를 보는 입장에서 괜찮은지 모르겠네요.그렇다고 분리하기에는 또 애매한거 같습니다.
지금처럼 속성이 적고 변할 가능성이 낮은 경우에는 인라인으로 정의하면서 타입을 붙이는게 나을까요 ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
이건 정답이 없는 것 같아요. 사실 너무 간단한 스타일이라 오히려 코드를 읽는 흐름을 망칠 것 같다는 생각이 들어요.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
인라인을 두는 방식을 선택하면서 타입까지 안전하게 할 수 있는 방법을 고려해 보시죠 ..