Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions frontend/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const BASE_URL = 'https://mock.chongchong.com';
11 changes: 9 additions & 2 deletions frontend/main.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import ReactDOM from 'react-dom/client';
import { QueryClientProvider, QueryClient } from '@tanstack/react-query';
import { createBrowserRouter, RouterProvider } from 'react-router';
import { Global } from '@emotion/react';
import { globalStyles } from './src/styles/global';
import App from './src/App';
import NoticeListPage from './src/features/notice/NoticeListPage';
import { routes as studiesRoutes } from './src/features/studies/routes';

const router = createBrowserRouter([
{
Expand All @@ -14,6 +16,7 @@ const router = createBrowserRouter([
path: '/studies/:studyId/notices',
element: <NoticeListPage />,
},
...studiesRoutes,
]);

const root = document.getElementById('root')!;
Expand All @@ -27,11 +30,15 @@ async function enableMocking() {
return worker.start();
}

const queryClient = new QueryClient();

enableMocking().then(() => {
ReactDOM.createRoot(root).render(
<>
<Global styles={globalStyles} />
<RouterProvider router={router} />
<QueryClientProvider client={queryClient}>
<Global styles={globalStyles} />
<RouterProvider router={router} />
</QueryClientProvider>
</>,
);
});
5 changes: 4 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@
},
"transformIgnorePatterns": [
"/node_modules/.pnpm/(?!(ky|rettime|until-async|outvariant|strict-event-emitter|headers-polyfill|is-node-process|@open-draft\\+[^@]+|@bundled-es-modules\\+[^@]+|@mswjs\\+[^@]+)@)"
]
],
"moduleNameMapper": {
"\\.(png|svg|jpg|jpeg|gif|woff2?|eot|ttf|otf)$": "<rootDir>/src/test/stub.ts"
}
},
"msw": {
"workerDirectory": [
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import ky from 'ky';
import { BASE_URL } from '../config';

const api = ky.create({
baseUrl: 'https://api.github.com/',
baseUrl: BASE_URL,
hooks: {},
});

Expand Down
12 changes: 12 additions & 0 deletions frontend/src/features/studies/api.ts
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('스터디 목록을 불러오는데 실패했습니다.');
}
}
28 changes: 28 additions & 0 deletions frontend/src/features/studies/components/MyStudies.tsx
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>
);
}
49 changes: 49 additions & 0 deletions frontend/src/features/studies/components/StudyList.tsx
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' }} />

Copy link
Copy Markdown
Contributor Author

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} /> 처럼 적용은 할 수 있겠지만 이런식으로 인라인에 정의하는 방식이 코드를 보는 입장에서 괜찮은지 모르겠네요.

그렇다고 분리하기에는 또 애매한거 같습니다.

지금처럼 속성이 적고 변할 가능성이 낮은 경우에는 인라인으로 정의하면서 타입을 붙이는게 나을까요 ?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이건 정답이 없는 것 같아요. 사실 너무 간단한 스타일이라 오히려 코드를 읽는 흐름을 망칠 것 같다는 생각이 들어요.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

인라인을 두는 방식을 선택하면서 타입까지 안전하게 할 수 있는 방법을 고려해 보시죠 ..

</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>
);
}
30 changes: 20 additions & 10 deletions frontend/src/features/studies/mocks/handlers.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,28 @@
import { http, HttpResponse } from 'msw';
import { BASE_URL } from '../../../../config';
import { STUDY_URLS } from '../urls';

export const handlers = [
http.get('https://mock.chongchong.com/studies', () => {
http.get(`${BASE_URL}${STUDY_URLS.list}`, () => {
return HttpResponse.json({
notifications: [
studies: [
{
id: 1,
title: '8월 스터디 운영 방식이 바뀝니다',
content: '8월부터 스터디 운영 방식을 변경하려고 합니다.',
createdAt: '2025-04-16 16:44:10',
memberCount: 4,
completeCount: 2,
remindedAt: '2025-04-16 16:44:10',
isComplete: false,
id: '1',
role: 'STUDY_LEADER',
title: '리액트 스터디',
description: '매주 화요일 10시에 진행하는 리액트 스터디',
memberCount: 3,
noticeCount: 2,
assignmentCount: 2,
},
{
id: '2',
role: 'SOME',
title: '우테코 8기 FE 스터디',
description: '매주 화요일 저녁 9시, 프론트엔드 CS와 코드 리뷰',
memberCount: 5,
noticeCount: 2,
assignmentCount: 1,
},
],
});
Expand Down
68 changes: 68 additions & 0 deletions frontend/src/features/studies/pages/MyStudiesPage.test.tsx
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);
});
71 changes: 71 additions & 0 deletions frontend/src/features/studies/pages/MyStudiesPage.tsx

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

개인적으로 페이지파일을 어디에 두는게 좋을지 고민하다가 그냥 studies 루트에 뒀습니다.
최대한 연관된 컴포넌트와 함께 두고 싶은 마음이 있어요.
예시를 들자면 MyStudies와 StudyList죠.
하지만 만약 둘 컴포넌트중에 재사용하는 컴포넌트가 존재하게 되면 이 위치는 애매하게 됩니다.
1, 2중에서 1에 둬야할지 2에 둬야할지 애매하니까요.
이런 경우를 생각하면 지금처럼 그냥 분리해서 두는게 괜찮겠다는 생각이 듭니다.
물론 현재는 페이지가 하나지만 만약 여러개가 되는 경우에도 괜찮을지는 의문이네요 🤔
혹시 디움은 이 부분에 대해서 어떻게 생각하시나요 ?
우리가 더 응집도 좋고 결합은 낮은 폴더구조를 만들 수 있을까요 ?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저라면 페이지가 여러 개인 경우가 생기면 pages라는 폴더를 만들어서 그 안에 페이지 파일을 위치시킬 것 같아요.
응집도가 높은 것과 결합도가 낮은 것 모두 중요하지만 폴더 구조에서 모든 것을 챙길 수는 없을 것 같아요...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

좋습니다. 의견을 들을 수 있어서 좋네요.
pages폴더를 주고 페이지 관련된 파일들을 모아두죠 !

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>
);
}
14 changes: 14 additions & 0 deletions frontend/src/features/studies/queries.ts
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;
9 changes: 9 additions & 0 deletions frontend/src/features/studies/routes.tsx
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 />,
},
];
11 changes: 11 additions & 0 deletions frontend/src/features/studies/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
type Role = 'STUDY_LEADER' | 'SOME';

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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;
}
3 changes: 3 additions & 0 deletions frontend/src/features/studies/urls.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const STUDY_URLS = {
list: '/studies/me',
};
2 changes: 1 addition & 1 deletion frontend/src/shared/ui/Badge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const variantStyle = {
},
NeutralSolid: {
background: tokens.bg.neutral,
color: tokens.text.onBrandStrong,
color: tokens.text.muted,
},
BrandOutline: {
background: tokens.bg.default,
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/shared/ui/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ const variantStyle = {
neutralOutline: {
background: tokens.bg.default,
border: tokens.border.neutral,
color: tokens.text.primary,
color: tokens.text.brand,
},
};

Expand Down
Loading
Loading