Skip to content

Commit

Permalink
[클린코드 리액트 3기 조용준] 페이먼츠 미션 Step2 (#148)
Browse files Browse the repository at this point in the history
* chore: chromatic fetch-depth 설정 제거

* refactor: src/shared 경로는 root re-export 개선

* feat: FormatInput, PinInput 컴포넌트 추가

* feat: FormatInput, PinInput stories 추가

* remove: CardInput 관련 컴포넌트 삭제

* feat: xstate를 적용한 Funnel 로직 변경

* refactor: @shared 코드 선언을 절차적으로 order 변경

* refacotr: 카드 추가 확인의 별칭이 빈 입력 값인 경우, 카드 브랜드 이름으로 반영

* refactor: Funnel 폴더 경로 이동 및 goToIndex assign 누락 개선

* refactor: FormatInput, PinInput 컴포넌트 적용, 카드 추가시 등록된 카드 검증, 생성일 추가

* refactor: Card 도메인 코드는 src/card path 변경 및 변수, 함수명을 컨벤션으로 개선

* refactor: 카드완료 페이지의 카드완료시 이미 등록된 카드는 별칭만 변경되도록 개선

* feat: 카드 클릭시 완료페이지 전환 및 카드 삭제 기능 추가

* refactor: 카드 도메인이 아닌 컴포넌트는 shared/components path 변경 및 컴포넌트 별 역할에 충족하는 storybook 테스트 추가

* refacotr: useToggle 관심사 제거 및 isValidateMonthString 네이밍 개선

* refactor: App.js Funnel index를 CardPageIndex를 활용해서 반영

* refactor: AppLayout 네이밍을 AppDisplay로 변경

* refactor: 컴포넌트의 return 상위에는 개행을 추가하도록 컨벤션 적용

* fix: letterSpacingValue의 fontSize unit 검증을 추가

* refactor: 반복되는 타입은 축약해서 활용하도록 개선

* refactor: 카드 추가 페이지의 Input constant 요소 분리

* docs: Step2 요구사항 문서 추가

* refactor: Card stories 타이틀 변경

* refactor: 컨벤션에 따른 함수명 네이밍 개선

* fix: FormatInputTextCounter의 inputRef 초기 변경 감지되지 않는 이슈 개선
  • Loading branch information
bytrustu authored Mar 18, 2024
1 parent 636e414 commit ecf68dc
Show file tree
Hide file tree
Showing 118 changed files with 2,137 additions and 1,315 deletions.
5 changes: 3 additions & 2 deletions .github/workflows/chromatic.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ on:
push:
branches:
- main
- bytrustu
paths:
- 'src/components/**'
- 'src/card/components/**'
- 'src/shared/components/**'
- 'src/shared/styles/**'

Expand All @@ -15,7 +16,7 @@ jobs:
steps:
- uses: actions/checkout@v2
with:
fetch-depth: 0 # 전체 Git 이력을 가져오도록 설정
fetch-depth: 0

- name: Setup Node.js
uses: actions/setup-node@v3
Expand Down
Binary file modified .yarn/install-state.gz
Binary file not shown.
71 changes: 71 additions & 0 deletions docs/STEP2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# 🚀 Getting Started
> 복잡한 흐름의 Stepper State Machine 으로 구현합니다.
- [x] 모바일 타겟의 웹 앱을 구현하며 사용하기 편리한 모바일 UI/UX에 대해 고민해봅니다.
- [x] Stepper 기반으로 작성한 애플리케이션을 State Machine XState로 구현합니다.
- Funnel (Funnel 기존 로직을 XState로 변경했어요.)
- [x] 재사용 가능한 Component를 직접 작성하고 사용합니다.
- [x] Controlled & Uncontrolled Components에 입각하여 Form을 핸들링합니다.

# 📝 Requirements
### 필수 요구사항
- [x] 원시적인 형태의 Primitive UI 형태의 컴포넌트 작성
- [x] Storybook 상호 작용 테스트
- [x] Controlled & Uncontrolled Components를 명확하게 구분하거나 선택하여 구현
- [x] 설계한 Stepper을 XState Visualizer를 활용하여 구현
### 카드 추가 확인
- [x] 이전 폼에서 입력된 카드를 보여준다.
- [x] 카드 별칭을 입력할 수 있다.
- [x] placeholder는 카드 별칭 (선택)이다.
- [x] 빈 입력값인 경우, 카드사 이름이 별칭으로 저장된다.
- [x] 최대 길이는 10자리이다.
- [x] 확인 버튼을 누르면, 카드 목록 페이지로 이동한다.
### 카드 목록
- [x] 카드 목록을 조회할 수 있다.
- [x] 카드 목록은 최신순(내림차순)으로 정렬된다.
- [x] 목록 최상단에 +을 누르면 카드 추가 페이지로 이동한다.
- `요구사항 변경` 최하단에 카드 추가영역을 고정하고, 카드 리스트는 스크롤되도록 한다.
- [x] 카드를 클릭하면, 카드 별칭 수정(카드 추가 완료 페이지)로 이동한다.
- [x] 카드를 삭제할 수 있다.


# 📚 리뷰 개선 사항
### 코드 구조 및 순서
- [x] 코드를 절차적으로 순서를 변경했어요.
- 파일 내 먼저 사용되는 함수를 상단으로 이동했어요.
- [x] 관심사의 사용 범위가 다른 파일은 관심사에 맞게 분류한다.
- Card 도메인 코드는 src/card 폴더로 분리했어요.
- [x] shared 폴더는 barrel 적용하여, re-export 한다.
- [x] provider 코드는 폴더를 별도로 분리한다.

### 컴포넌트 및 UI
- [x] 컴포넌트는 활용 용도에 맞게 Storybook 테스트를 작성한다.
- Flex, Stack, Grid 등 컴포넌트 역할에 충족하도록 작성했어요.
- [x] CardInput 컴포넌트는 도메인을 제거한다.
- Card에 종속되었던 Input 컴포넌트를 개선했어요.
- FormatInput
- PinInput
- [x] AppLayout -> AppDisplay 로 네이밍 변경한다.
- [x] CardDisPlay 컴포넌트의 expiration props는 내부에서 분리한다.
- [x] UI에서 사용하는 props는 행위에 맞게 네이밍한다.
- [x] 컴포넌트의 return 상위에는 개행을 추가한다.

### 함수 및 변수 네이밍
- [x] handle, on, submit, change 등의 이벤트 핸들러는 명확한 용도를 가지고 있어야 한다.
- [x] ~is prefix 네이밍은 함수에 대한 Boolean 값을 반환한다.
- [x] 함수는 is prefix, 변수는 ~ed suffix를 사용했어요.

### 타입 및 유틸리티
- [x] HTML tag 자체 node 타입의 정의는 제거한다. (button type)
- [x] Pick, Omit 유틸리티 타입은 적합한 상황에 맞게 사용한다.
- [x] 반복되는 타입은 축약해서 활용한다. | grid${'Gap'| 'RowGap' | '...'}
- [x] 형식이 정해져있는 값은 타입을 명확하게 정의한다. CardNumber, ExpirationDate, SecurityCode, Password

### Hooks
- [x] hooks의 반환 값은 도메인을 포함하지 않고, 최대한 단순하게 유지한다.
- [x] value, selected
- [x] 하나의 hook에 너무 많은 역할을 부여하지 않는다.

### 기타
- [x] letterSpacingValue의 fontSize에 대한 수치 변환에 대한 검증을 추가한다.
- [x] 사용하지 않는 불필요한 코드는 제거한다.
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@
"dependencies": {
"@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.0",
"@xstate/react": "^4.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
"react-dom": "^18.2.0",
"xstate": "^5.9.1"
},
"devDependencies": {
"@storybook/addon-essentials": "^7.6.17",
Expand Down
15 changes: 8 additions & 7 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
import { AppLayout, CardProvider, Funnel } from '@/components';
import { CardAddPage, CardCompletePage, CardListPage } from '@/pages';
import { CardProvider } from 'src/card/providers';
import { CardAddPage, CardCompletePage, CardListPage, CardPageIndex } from '@/card';
import { AppDisplay, Funnel } from '@/shared';

const App = () => (
<AppLayout.Root>
<AppDisplay.Root>
<CardProvider>
<Funnel.Root>
<Funnel.Step index={0}>
<Funnel.Step index={CardPageIndex.CardListPage}>
<CardListPage />
</Funnel.Step>
<Funnel.Step index={1}>
<Funnel.Step index={CardPageIndex.CardAddPage}>
<CardAddPage />
</Funnel.Step>
<Funnel.Step index={2}>
<Funnel.Step index={CardPageIndex.CardCompletePage}>
<CardCompletePage />
</Funnel.Step>
</Funnel.Root>
</CardProvider>
</AppLayout.Root>
</AppDisplay.Root>
);
export default App;
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { Button, Typography } from '@/shared/components';
import { styleToken } from '@/shared/styles';
import { styleToken, Button, Typography } from '@/shared';

type CardAddDisplayProps = {
onClick?: () => void;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import { Meta, StoryObj } from '@storybook/react';
import { CardDisplay } from './CardDisplay';
import { AppLayout } from '@/components';
import { VStack } from '@/shared/components';
import { styleToken } from '@/shared/styles';
import { CardDisplay } from '@/card';
import { AppDisplay, VStack, styleToken } from '@/shared';

const meta: Meta<typeof CardDisplay> = {
title: 'Components/CardDisplay',
title: 'CARD/CardDisplay',
component: CardDisplay,
parameters: {
layout: 'centered',
Expand All @@ -14,11 +12,11 @@ const meta: Meta<typeof CardDisplay> = {
argTypes: {},
decorators: [
(Story) => (
<AppLayout>
<AppDisplay>
<VStack justifyContent="center" alignItems="center">
<Story />
</VStack>
</AppLayout>
</AppDisplay>
),
],
};
Expand All @@ -33,8 +31,8 @@ export const Primary: Story = {
size="small"
label="Near 카드"
color={styleToken.color.teal200}
cardNumber={['1234', '1234', '1234', '1234']}
expirationDate={['12', '12']}
cardNumber="1234 1234 1234 1234"
expirationDate="12 12"
ownerName="Near"
/>
),
Expand All @@ -46,8 +44,8 @@ export const WithBackgroundColor: Story = {
size="small"
label="Near 카드"
color={styleToken.color.gray200}
cardNumber={['1234', '1234', '1234', '1234']}
expirationDate={['12', '12']}
cardNumber="1234 1234 1234 1234"
expirationDate="12 12"
ownerName="Near"
/>
),
Expand All @@ -59,8 +57,8 @@ export const WithSizeBig: Story = {
size="big"
label="Near 카드"
color={styleToken.color.teal200}
cardNumber={['1234', '1234', '1234', '1234']}
expirationDate={['12', '12']}
cardNumber="1234 1234 1234 1234"
expirationDate="12 12"
ownerName="Near"
/>
),
Expand All @@ -72,8 +70,8 @@ export const WithNoLabel: Story = {
size="small"
label=""
color={styleToken.color.teal200}
cardNumber={['1234', '1234', '1234', '1234']}
expirationDate={['12', '12']}
cardNumber="1234 1234 1234 1234"
expirationDate="12 12"
ownerName="Near"
/>
),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,13 @@
import { CardAddDisplay } from './CardAddDisplay';
import { Box, Button, Grid, HStack, Typography, VStack } from '@/shared/components';
import { styleToken } from '@/shared/styles';
import { StyleProps } from '@/shared/types';
import { replaceMaskText } from '@/shared/utils';
import { CardState } from '@/type';
import { CardState } from '@/card';
import { Box, Button, Grid, HStack, Typography, VStack, styleToken, StyleProps, replaceMaskText } from '@/shared';

type CardSize = 'big' | 'small';
type CardTypographyVariant = 'headline' | 'body';
type CardDisplayProps = {
size: CardSize;
onClick?: () => void;
} & Pick<CardState, 'label' | 'color' | 'cardNumber' | 'expirationDate' | 'ownerName'>;
} & Record<keyof Omit<CardState, 'securityCode' | 'password' | 'description' | 'createdTimestamp'>, string>;

export const CardDisplay = ({
size,
Expand All @@ -22,7 +19,8 @@ export const CardDisplay = ({
onClick,
}: CardDisplayProps) => {
const { cardDisplayProps, cardChipProps, typographyVariant, maskFontSize } = getCardStyles(size);
const [expirationMonth, expirationYear] = expirationDate;
const [expirationMonth, expirationYear] = expirationDate.split(' ');

return (
<Button onClick={onClick} padding="0">
<VStack
Expand Down Expand Up @@ -63,9 +61,10 @@ const CardNumberDisplay = ({
maskFontSize: StyleProps['fontSize'];
}) => (
<Grid gridTemplateColumns="repeat(4, 1fr)" width="100%" paddingLeft="20px">
{cardNumber.map((number, index) => {
{cardNumber.split(' ').map((number, index) => {
const isTextMaskIndex = index > 1;
const text = isTextMaskIndex ? replaceMaskText(number) : number;

return (
<Typography
key={`card-number-${index}`}
Expand Down Expand Up @@ -128,6 +127,7 @@ const getCardStyles = (size: CardSize) => {
return { cardDisplayProps, cardChipProps, typographyVariant, maskFontSize };
};

CardDisplay.displayName = 'CardDisplay';

CardDisplay.Root = CardDisplay;
CardDisplay.Add = CardAddDisplay;
CardDisplay.displayName = 'CardDisplay';
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -1,25 +1,25 @@
import { Meta, StoryObj } from '@storybook/react';
import { CardSelectBottomSheet } from './CardSelectBottomSheet';
import { AppLayout } from '@/components';
import { CardBrand } from '@/type';
import { CardBrand, CardSelectBottomSheet } from '@/card';
import { AppDisplay } from '@/shared';

const meta: Meta<typeof CardSelectBottomSheet> = {
title: 'Components/CardSelectBottomSheet',
title: 'CARD/CardSelectBottomSheet',
component: CardSelectBottomSheet,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
args: {
onSubmit: (cardBrand: CardBrand) => {
opened: true,
onCardBrandClick: (cardBrand: CardBrand) => {
alert(`선택한 카드: ${cardBrand.label}`);
},
},
decorators: [
(Story) => (
<AppLayout>
<AppDisplay>
<Story />
</AppLayout>
</AppDisplay>
),
],
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import type { CardBrand } from '@/card';
import { CARD_BRANDS } from '@/card';
import { Backdrop, Button, Circle, Grid, Typography, VStack, styleToken } from '@/shared';

type CardSelectButtonProps = {
onClick: () => void;
} & CardBrand;

type CardSelectBottomSheetProps = {
opened: boolean;
onOverlayClick?: () => void;
onCardBrandClick?: (cardBrand: CardBrand) => void;
};

const CardSelectButton = ({ color, label, ...props }: CardSelectButtonProps) => (
<Button variant="ghost" backgroundColor={styleToken.color.white} width="100%" padding="0" {...props}>
<VStack width="100%" justifyContent="center" alignItems="center" spacing="10px">
<Circle backgroundColor={color} width="36px" height="36px" />
<Typography variant="caption" color={styleToken.color.black}>
{label}
</Typography>
</VStack>
</Button>
);

export const CardSelectBottomSheet = ({ opened, onOverlayClick, onCardBrandClick }: CardSelectBottomSheetProps) =>
opened ? (
<Backdrop onClick={onOverlayClick}>
<VStack
position="absolute"
bottom="0"
left="0"
width="100%"
height="230px"
backgroundColor="white"
borderRadius="5px 5px 15px 15px"
>
<Grid
gridTemplateColumns="repeat(4, 1fr)"
alignItems="center"
justifyContent="center"
height="100%"
padding="20px 20px"
>
{CARD_BRANDS.map(({ label, color }) => (
<CardSelectButton
key={`card-select-${label}`}
color={color}
label={label}
onClick={() => {
onCardBrandClick?.({ label, color });
}}
/>
))}
</Grid>
</VStack>
</Backdrop>
) : null;

CardSelectBottomSheet.displayName = 'CardSelectBottomSheet';
2 changes: 2 additions & 0 deletions src/card/components/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './CardDisplay';
export * from './CardSelectBottomSheet';
24 changes: 24 additions & 0 deletions src/card/constants/cardBrands.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { CardState } from '@/card';
import { styleToken } from '@/shared';

export enum CardBrandName {
'레냥카드' = '레냥카드',
'블냥카드' = '블냥카드',
'초냥카드' = '초냥카드',
'자냥카드' = '자냥카드',
'로냥카드' = '로냥카드',
'골냥카드' = '골냥카드',
'깜냥카드' = '깜냥카드',
'신냥카드' = '신냥카드',
}

export const CARD_BRANDS: Pick<CardState, 'label' | 'color'>[] = [
{ label: CardBrandName.레냥카드, color: styleToken.color.crimson },
{ label: CardBrandName.블냥카드, color: styleToken.color.azure },
{ label: CardBrandName.초냥카드, color: styleToken.color.mint },
{ label: CardBrandName.자냥카드, color: styleToken.color.fuchsia },
{ label: CardBrandName.로냥카드, color: styleToken.color.rose },
{ label: CardBrandName.골냥카드, color: styleToken.color.gold },
{ label: CardBrandName.깜냥카드, color: styleToken.color.black },
{ label: CardBrandName.신냥카드, color: styleToken.color.teal200 },
] as const;
Loading

0 comments on commit ecf68dc

Please sign in to comment.