Skip to content
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

사이드바 구현 #76

Open
wants to merge 18 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
d891a2a
[#67] feat: IconTabNavbar 추가
hseoy Dec 31, 2021
ddfa546
[#67] chore: sidebar에 필요한 이미지 리소스 추가
hseoy Dec 31, 2021
4258290
[#67] refactor: TabIcon에 대해 TabItem 컴포넌트 prop을 상속하도록 개선
hseoy Jan 1, 2022
aece736
[#67] test: IconTabNavbar에 대한 컴포넌트 테스트 추가
hseoy Jan 1, 2022
4d8f828
[#67] refactor: IconTabNavbar 컴포넌트의 display를 inline-flex로 수정
hseoy Jan 1, 2022
b9d2294
[#67] refactor: IconTabNavbar를 IconTabBar로 네이밍 변경 및 common 디렉토리로 이동
hseoy Jan 1, 2022
ca1d915
[#67] feat: Sidebar 컴포넌트 추가
hseoy Jan 1, 2022
c303edd
[#67] refactor: Layout 컴포넌트를 layout 디렉토리로 이동
hseoy Jan 1, 2022
10e29c1
[#67] refactor: Sidebar 내용물의 height를 100%로 만들어주기 위한 스타일 변경
hseoy Jan 1, 2022
4d2d607
[#67] refactor: 사이드바에서 로고 클릭 시 서비스 리스트 페이지로 이동하도록 URL 수정
hseoy Jan 1, 2022
0a8d097
[#67] feat: SideLayout 컴포넌트 추가
hseoy Jan 1, 2022
d5ce487
[#67] feat: FeedbackMessagePage 추가
hseoy Jan 1, 2022
d01eadd
[#67] refactor: 서비스 수정 페이지에서 Layout 컴포넌트 대신 SideLayout 컴포넌트 사용하도록 수정
hseoy Jan 1, 2022
1c3d672
[#67] test: storybook decorator에 BrowserRouter 컴포넌트 추가
hseoy Jan 1, 2022
278ffbe
[#67] test: storybook 분류를 Page 대신 Components 사용
hseoy Jan 1, 2022
c1cfb8e
[#67] test: IconTabBar storybook story 추가
hseoy Jan 1, 2022
3d843aa
[#67] refactor: Page.stories.tsx를 Jumbotron.stories.tsx로 네이밍 변경
hseoy Jan 1, 2022
d6c9cf6
[#67] test: Sidebar storybook story 추가
hseoy Jan 1, 2022
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
5 changes: 4 additions & 1 deletion .storybook/preview.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { RecoilRoot } from 'recoil';
import { ThemeProvider } from '@emotion/react';
import GlobalStyle from '../src/styles/global';
import theme from '../src/styles/theme';
import { BrowserRouter } from 'react-router-dom';

export const parameters = {
actions: { argTypesRegex: '^on[A-Z].*' },
Expand All @@ -18,7 +19,9 @@ export const decorators = [
<RecoilRoot>
<ThemeProvider theme={theme}>
<GlobalStyle />
<Story />
<BrowserRouter>
<Story />
</BrowserRouter>
</ThemeProvider>
</RecoilRoot>
),
Expand Down
1 change: 1 addition & 0 deletions src/@types/image.d.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
declare module '*.jpg';
declare module '*.png';
declare module '*.svg';
14 changes: 13 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import React, { FC } from 'react';
import { Route, Switch } from 'react-router-dom';

import PrivateRoute from './components/common/PrivateRoute';
import ErrorBoundary from './components/common/ErrorBoundary';

import Uri from './constants/uri';

import HomePage from './pages/HomePage';
import NotFoundPage from './pages/NotFoundPage';
import ServiceEditPage from './pages/ServiceEditPage';
import ServicePage from './pages/ServicePage';
import SigninPage from './pages/SigninPage';
import SignupPage from './pages/SignupPage';
import PrivateRoute from './components/common/PrivateRoute';
import FeedbackMessagePage from './pages/FeedbackMessagePage';

import { useUserStateValue } from './atoms/userState';

const App: FC = () => {
Expand Down Expand Up @@ -39,7 +43,15 @@ const App: FC = () => {
component={ServicePage}
isAccessible={isAuthenticated}
/>

{/*
@TODO PrivateRoute로 변경
요 두 개의 페이지는 원래 Private으로 가야 하지만 아직은 테스트 용이성을 위해 Route로 남깁니다.
추후 채팅을 구현할 때 PrivateRoute로 변경합시다!
*/}
<Route path={Uri.serviceEdit} exact component={ServiceEditPage} />
Comment on lines +47 to 52
Copy link
Member

Choose a reason for hiding this comment

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

👍🏻👍🏻 ServiceEditPage 라우터는 나중에 올라올 서비스 수정 API 연동 PR에서 수정되었습니닷!!!

<Route path={Uri.feedback} exact component={FeedbackMessagePage} />

<Route component={NotFoundPage} />
</Switch>
</ErrorBoundary>
Expand Down
3 changes: 3 additions & 0 deletions src/assets/images/chat-icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions src/assets/images/setting-icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
139 changes: 139 additions & 0 deletions src/components/common/IconTabBar/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import React from 'react';
import { MemoryRouter, Router } from 'react-router-dom';
import { createMemoryHistory } from 'history';

import { render, screen, fireEvent } from '@testing-library/react';

import IconTabBar from '.';

describe('<IconTabBar/> component test', () => {
it('탭 요소들을 렌더링 해야 합니다.', () => {
render(
<MemoryRouter>
<IconTabBar>
<IconTabBar.TabItem>ITEM 1</IconTabBar.TabItem>
<IconTabBar.TabItem>ITEM 2</IconTabBar.TabItem>
<IconTabBar.TabItem>ITEM 3</IconTabBar.TabItem>
<IconTabBar.TabIcon src="#">ICON 1</IconTabBar.TabIcon>
<IconTabBar.TabIcon src="#">ICON 2</IconTabBar.TabIcon>
<IconTabBar.TabIcon src="#">ICON 3</IconTabBar.TabIcon>
</IconTabBar>
</MemoryRouter>,
);

expect(screen.queryByText('ITEM 1')).toBeInTheDocument();
expect(screen.queryByText('ITEM 2')).toBeInTheDocument();
expect(screen.queryByText('ITEM 3')).toBeInTheDocument();

expect(screen.queryByAltText('ICON 1')).toBeInTheDocument();
expect(screen.queryByAltText('ICON 2')).toBeInTheDocument();
expect(screen.queryByAltText('ICON 3')).toBeInTheDocument();
});

describe('<IconTabBar.TabItem/> component test', () => {
it('자식 요소를 렌더링 해야 합니다.', () => {
render(
<MemoryRouter>
<IconTabBar.TabItem>Content</IconTabBar.TabItem>
</MemoryRouter>,
);

expect(screen.queryByText('Content')).toBeInTheDocument();
});

it('함수로 전달된 자식 요소에 URL을 기반으로한 선택 여부를 전달해야 합니다.', () => {
render(
<MemoryRouter initialEntries={['/target']}>
<IconTabBar.TabItem to="/target">
{(selected) => selected && 'TRUE'}
</IconTabBar.TabItem>
</MemoryRouter>,
);

expect(screen.queryByText('TRUE')).toBeInTheDocument();
});

it('클릭 핸들러가 지정된 탭 요소를 클릭하면 해당 클릭 핸들러가 호출되어야 합니다.', () => {
let isClickHandlerCalled = false;

render(
<MemoryRouter>
<IconTabBar.TabItem
onClickHandler={() => {
isClickHandlerCalled = true;
}}
>
ITEM
</IconTabBar.TabItem>
</MemoryRouter>,
);

fireEvent.click(screen.getByText('ITEM'));

expect(isClickHandlerCalled).toBeTruthy();
});

it('링크 URL이 지정된 탭 요소를 클릭하면 해당 URL로 이동해야 합니다.', () => {
const history = createMemoryHistory();

render(
<Router history={history}>
<IconTabBar.TabItem to="/target">ITEM</IconTabBar.TabItem>
</Router>,
);

fireEvent.click(screen.getByText('ITEM'));

expect(history.location.pathname).toBe('/target');
});
});

describe('<IconTabBar.TabIcon/> component test', () => {
const DEFAULT_ALT_TEXT = 'icon';

it('클릭 핸들러가 지정된 아이콘을 클릭하면 해당 클릭 핸들러가 호출되어야 합니다.', () => {
let isClickHandlerCalled = false;

render(
<MemoryRouter>
<IconTabBar.TabIcon
src="#"
onClickHandler={() => {
isClickHandlerCalled = true;
}}
/>
</MemoryRouter>,
);

fireEvent.click(screen.getByRole('img', { name: DEFAULT_ALT_TEXT }));

expect(isClickHandlerCalled).toBeTruthy();
});

it('링크 URL이 지정된 아이콘을 클릭하면 해당 URL로 이동해야 합니다.', () => {
const history = createMemoryHistory();

render(
<Router history={history}>
<IconTabBar.TabIcon src="#" to="/target" />
</Router>,
);

fireEvent.click(screen.getByRole('img', { name: DEFAULT_ALT_TEXT }));

expect(history.location.pathname).toBe('/target');
});

it('아이콘 이미지의 대체 텍스트를 지정할 수 있어야 합니다.', () => {
render(
<MemoryRouter>
<IconTabBar.TabIcon src="#" to="/target">
Icon Alt Text
</IconTabBar.TabIcon>
</MemoryRouter>,
);

expect(screen.queryByAltText('Icon Alt Text')).toBeInTheDocument();
});
});
});
118 changes: 118 additions & 0 deletions src/components/common/IconTabBar/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { Theme } from '@emotion/react';
import React, { FC, MouseEventHandler } from 'react';
import { useHistory, useLocation } from 'react-router-dom';

import {
tabBarStyle,
tabIconStyle,
tabItemStyle,
tabCurvedBlockStyle,
} from './style';

type TabItemProps = {
to?: string;
onClickHandler?: MouseEventHandler<HTMLButtonElement>;
children: React.ReactNode | ((selected: boolean) => React.ReactNode);
};

const TabItem: FC<TabItemProps> = ({ children, to, onClickHandler }) => {
const history = useHistory();
const location = useLocation();

const onClickTabItem: MouseEventHandler<HTMLButtonElement> = (e) => {
if (to) {
history.push(to);
}
if (onClickHandler) {
onClickHandler(e);
}
};

return (
<button
type="button"
className="tab-item"
css={tabItemStyle}
onClick={onClickTabItem}
>
{typeof children === 'function'
? children(to === location.pathname)
: children}
</button>
);
};

TabItem.defaultProps = {
to: null,
onClickHandler: null,
};

type TabIconProps = {
src: string;
children?: string;
selectable?: boolean;
} & Omit<TabItemProps, 'children'>;

const TabIcon: FC<TabIconProps> = ({
children,
src,
to,
selectable,
onClickHandler,
}) => {
return (
<TabItem to={to} onClickHandler={onClickHandler}>
{(selected) => (
<div
css={tabCurvedBlockStyle}
className={`curved-block-wrap ${
!selected || !selectable ? 'not-selected' : ''
}`}
>
<div className="curved-block start" />
<div
css={tabIconStyle}
className={`tab-icon ${selected && selectable ? 'selected' : ''}`}
>
<img src={src} alt={children} />
Copy link
Member

Choose a reason for hiding this comment

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

alt 를 alt 같은 prop 이 아닌 children prop 으로 받는 이유가 궁금합니다
약간 직관적이지 않다고 느껴져서요 😅

Copy link
Member

Choose a reason for hiding this comment

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

넹 제 생각도 children으로 안 받고 이미지 alt를 이 이미지 역할 정도로만 정의해줘도 될 것 같아요!

Copy link
Member Author

Choose a reason for hiding this comment

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

TabIcon이라는 네이밍에서 이것은 아이콘만을 보여주는 컴포넌트라는 것을 드러냈다면 굳이 alt와 같이 이미지 태그스러운 속성을 사용할 필요가 있을까 했습니다. 그래서 children으로 받아 일종의 숨김을 사용한 것이었는 데 두 분의 의견이 직관적이지 않다로 일치하신 것 같아 alt 속성을 사용한 형태로 수정수정,,,,

</div>
<div className="curved-block end" />
</div>
)}
</TabItem>
);
};

TabIcon.defaultProps = {
children: 'icon',
selectable: true,
};

type IconTabBarProps = {
direction?: 'top' | 'right' | 'bottom' | 'left';
};

const IconTabBar: FC<IconTabBarProps> & {
TabIcon: FC<TabIconProps>;
TabItem: FC<TabItemProps>;
} = ({ children, direction }) => {
const isRow = direction === 'top' || direction === 'bottom';

return (
<div
className={direction}
css={(cssTheme: Theme) => tabBarStyle(cssTheme, isRow)}
>
{children}
</div>
);
};

IconTabBar.defaultProps = {
direction: 'right',
};

IconTabBar.TabIcon = TabIcon;
IconTabBar.TabItem = TabItem;
Comment on lines +115 to +116
Copy link
Member

Choose a reason for hiding this comment

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

확실히 Compound Components 를 사용하니 훨씬 수정하기 용이하게 느껴집니다!!
다만 이전에 말씀하신 것처럼 한 모듈에 모든 컴포넌트가 있으니 가독성이 떨어지긴 하네요 ㅠㅠ
TabIcon, TabItem 을 같은 폴더 내의 모듈로 만들고, import 해서 사용하는건 어떨까요...?

Suggested change
IconTabBar.TabIcon = TabIcon;
IconTabBar.TabItem = TabItem;
import TabIcon from "./TabIcon";
import TabItem from "./TabItem";
// ...
IconTabBar.TabIcon = TabIcon;
IconTabBar.TabItem = TabItem;

Copy link
Member Author

Choose a reason for hiding this comment

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

좋습니당


export default IconTabBar;
Loading