Skip to content

Commit

Permalink
feat: 같은 플로우 안의 여러 페이지를 관리하기 위한 퍼널 추상화 및 적용 (#482)
Browse files Browse the repository at this point in the history
* refactor/#473: navigate 관련 코드 훅 분리

* feat/#473: Funnel, useFunnel 구현

* feat/#473: NonEmptyArray 타입 추가

* feat/#473: ReviewFormFunnel 적용

* refactor/#473: Review페이지 경로 수정

* feat/#473: 템플릿 overflow-y auto로 변경

* feat/#473: 스크롤 디자인 안보이도록 수정

* feat/#473: useFunnel 반환값 객체로 수정

* feat/#473: PetProfile 등록 폼 Funnel 적용

* fix/#473: 믹스견인 경우에만 petSize 기본값 넣어주도록 수정

* feat/#473: 펫 등록 폼 작성 시 페이지 이동해도 상태가 유지되도록 설정

* feat/#473: 폼 작성 중 사용자의 실수를 예방하기 위한 goBackSafely 적용

* feat/#473: 리뷰 스텝 상수화

* fix/#473: 0살(1살미만)일 때 다음 버튼 활성화되도록 수정

* fix/#473: ReviewForm 테스트 깨지는 문제 해결

* refactor/#473: 코드 컨벤션 맞추기 및 불필요한 코드 삭제

* refactor/#473: 인라인 분기문 변수로 추출
  • Loading branch information
HyeryongChoi authored Oct 13, 2023
1 parent 7a7d415 commit e3240ef
Show file tree
Hide file tree
Showing 38 changed files with 657 additions and 495 deletions.
41 changes: 41 additions & 0 deletions frontend/src/components/@common/Funnel/Funnel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Children, isValidElement, PropsWithChildren, ReactElement, useEffect } from 'react';

import { NonEmptyArray } from '@/types/common/utility';
import { RuntimeError } from '@/utils/errors';

export interface FunnelProps<Steps extends NonEmptyArray<string>> {
steps: Steps;
step: Steps[number];
children: Array<ReactElement<StepProps<Steps>>> | ReactElement<StepProps<Steps>>;
}

export interface StepProps<Steps extends NonEmptyArray<string>> extends PropsWithChildren {
name: Steps[number];
onNext?: VoidFunction;
}

export const Funnel = <Steps extends NonEmptyArray<string>>(props: FunnelProps<Steps>) => {
const { steps, step, children } = props;
const validChildren = Children.toArray(children)
.filter(isValidElement<StepProps<Steps>>)
.filter(({ props }) => steps.includes(props.name));

const targetStep = validChildren.find(child => child.props.name === step);

if (!targetStep) {
throw new RuntimeError(
{ code: 'WRONG_URL_FORMAT' },
`${step} 스텝 컴포넌트를 찾지 못했습니다.`,
);
}

return targetStep;
};

export const Step = <T extends NonEmptyArray<string>>({ onNext, children }: StepProps<T>) => {
useEffect(() => {
onNext?.();
}, [onNext]);

return children;
};
2 changes: 1 addition & 1 deletion frontend/src/components/@common/Template.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export default Template;
const Layout = styled.div`
position: relative;
overflow-y: scroll;
overflow-y: auto;
width: 100vw;
min-height: calc(var(--vh, 1vh) * 100);
Expand Down
16 changes: 8 additions & 8 deletions frontend/src/components/PetProfile/PetAgeSelect.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,25 @@
import { SelectHTMLAttributes } from 'react';
import { ComponentPropsWithoutRef } from 'react';
import { styled } from 'styled-components';

interface PetAgeSelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
initialAge?: number;
interface PetAgeSelectProps extends ComponentPropsWithoutRef<'select'> {
defaultAge?: number;
}

const PetAgeSelect = (petAgeSelectProps: PetAgeSelectProps) => {
const { initialAge, ...restProps } = petAgeSelectProps;
const { defaultAge, ...restProps } = petAgeSelectProps;

return (
<AgeSelect name="pet-age" aria-label="반려동물 나이 선택" {...restProps}>
<option disabled value={undefined} selected={!initialAge}>
<option disabled selected={!defaultAge || Boolean(defaultAge < 0)}>
여기를 눌러 아이의 나이를 선택해주세요.
</option>
<option value={0} selected={initialAge === 0}>
<option value={0} selected={defaultAge === 0}>
1살 미만
</option>
{Array.from({ length: 19 }, (_, index) => index + 1).map(age => (
<option selected={age === initialAge} key={`${age}살`} value={age}>{`${age}살`}</option>
<option selected={age === defaultAge} key={`${age}살`} value={age}>{`${age}살`}</option>
))}
<option value={20} selected={initialAge === 20}>
<option value={20} selected={defaultAge === 20}>
20살 이상
</option>
</AgeSelect>
Expand Down
12 changes: 7 additions & 5 deletions frontend/src/components/PetProfile/PetBreedSelect.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,23 @@
import { SelectHTMLAttributes, Suspense } from 'react';
import { ComponentPropsWithoutRef } from 'react';
import { styled } from 'styled-components';

import { useBreedListQuery } from '@/hooks/query/petProfile';

interface PetBreedSelectProps extends SelectHTMLAttributes<HTMLSelectElement> {}
interface PetBreedSelectProps extends ComponentPropsWithoutRef<'select'> {
defaultBreed?: string;
}

const PetBreedSelect = (petBreedSelectProps: PetBreedSelectProps) => {
const { ...restProps } = petBreedSelectProps;
const { defaultBreed, ...restProps } = petBreedSelectProps;
const { breedList } = useBreedListQuery();

return (
<BreedSelect name="pet-breed" aria-label="견종 선택" {...restProps}>
<option disabled selected value={undefined}>
<option disabled selected={!defaultBreed}>
여기를 눌러 아이의 견종을 선택해주세요.
</option>
{breedList?.map(breed => (
<option key={breed.name} value={breed.name}>
<option key={breed.name} value={breed.name} selected={defaultBreed === breed.name}>
{breed.name}
</option>
))}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ const PetProfileEditionForm = () => {
</div>
<div>
<InputLabel htmlFor="pet-age">나이 선택</InputLabel>
<PetAgeSelect id="pet-age" onChange={onChangeAge} initialAge={pet.age} />
<PetAgeSelect id="pet-age" onChange={onChangeAge} defaultAge={pet.age} />
<ErrorCaption>{isValidAgeSelect ? '' : '나이를 선택해주세요!'} </ErrorCaption>
</div>
<div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,23 @@ import { useEffect } from 'react';
import { styled } from 'styled-components';

import CameraIcon from '@/assets/svg/camera_icon.svg';
import DefaultDogIcon from '@/assets/svg/dog_icon.svg';
import { usePetAdditionContext } from '@/context/petProfile/PetAdditionContext';
import { useImageUpload } from '@/hooks/common/useImageUpload';

const PetProfileImageUploader = () => {
const { updatePetProfile } = usePetAdditionContext();
const { petProfile, updatePetProfile } = usePetAdditionContext();
const { previewImage, imageUrl, uploadImage } = useImageUpload();

useEffect(() => {
updatePetProfile({ imageUrl });
if (imageUrl) updatePetProfile({ imageUrl });
}, [imageUrl]);

return (
<ImageUploadLabel aria-label="사진 업로드하기">
<input type="file" accept="image/*" onChange={uploadImage} />
<PreviewImageWrapper>
{previewImage && <PreviewImage src={previewImage} alt="" />}
<PreviewImage src={petProfile.imageUrl || previewImage || DefaultDogIcon} alt="" />
</PreviewImageWrapper>
<CameraIconWrapper>
<img src={CameraIcon} alt="" />
Expand Down
99 changes: 78 additions & 21 deletions frontend/src/components/Review/ReviewForm/ReviewForm.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import userEvent from '@testing-library/user-event';
import React from 'react';

import { COMMENT_LIMIT } from '../../../constants/review';
import { useReviewForm } from '../../../hooks/review/useReviewForm';
import ReviewForm from './ReviewForm';

const meta = {
Expand All @@ -17,16 +18,32 @@ export default meta;
type Story = StoryObj<typeof ReviewForm>;

export const Basic: Story = {
args: {
petFoodId: 1,
rating: 5,
args: {},

render: args => {
const reviewData = useReviewForm({
petFoodId: 1,
rating: 5,
isEditMode: false,
reviewId: 1,
});

return <ReviewForm reviewData={reviewData} />;
},
};

export const ValidForm: Story = {
args: {
petFoodId: 1,
rating: 5,
args: {},

render: args => {
const reviewData = useReviewForm({
petFoodId: 1,
rating: 5,
isEditMode: false,
reviewId: 1,
});

return <ReviewForm reviewData={reviewData} />;
},

play: async ({ canvasElement }) => {
Expand All @@ -48,9 +65,17 @@ export const ValidForm: Story = {
};

export const InvalidForm: Story = {
args: {
petFoodId: 1,
rating: 5,
args: {},

render: args => {
const reviewData = useReviewForm({
petFoodId: 1,
rating: 5,
isEditMode: false,
reviewId: 1,
});

return <ReviewForm reviewData={reviewData} />;
},

play: async ({ canvasElement }) => {
Expand All @@ -72,9 +97,17 @@ export const InvalidForm: Story = {
};

export const SingleSelectionTestForTastePreference: Story = {
args: {
petFoodId: 1,
rating: 5,
args: {},

render: args => {
const reviewData = useReviewForm({
petFoodId: 1,
rating: 5,
isEditMode: false,
reviewId: 1,
});

return <ReviewForm reviewData={reviewData} />;
},

play: async ({ canvasElement }) => {
Expand All @@ -101,9 +134,17 @@ export const SingleSelectionTestForTastePreference: Story = {
};

export const SingleSelectionTestForStoolCondition: Story = {
args: {
petFoodId: 1,
rating: 5,
args: {},

render: args => {
const reviewData = useReviewForm({
petFoodId: 1,
rating: 5,
isEditMode: false,
reviewId: 1,
});

return <ReviewForm reviewData={reviewData} />;
},

play: async ({ canvasElement }) => {
Expand All @@ -130,9 +171,17 @@ export const SingleSelectionTestForStoolCondition: Story = {
};

export const MultipleSelectionTestForAdverseReaction: Story = {
args: {
petFoodId: 1,
rating: 5,
args: {},

render: args => {
const reviewData = useReviewForm({
petFoodId: 1,
rating: 5,
isEditMode: false,
reviewId: 1,
});

return <ReviewForm reviewData={reviewData} />;
},

play: async ({ canvasElement }) => {
Expand Down Expand Up @@ -163,9 +212,17 @@ export const MultipleSelectionTestForAdverseReaction: Story = {
};

export const NoneButtonDeselectOthersTestForAdverseReaction: Story = {
args: {
petFoodId: 1,
rating: 5,
args: {},

render: args => {
const reviewData = useReviewForm({
petFoodId: 1,
rating: 5,
isEditMode: false,
reviewId: 1,
});

return <ReviewForm reviewData={reviewData} />;
},

play: async ({ canvasElement }) => {
Expand Down
29 changes: 11 additions & 18 deletions frontend/src/components/Review/ReviewForm/ReviewForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,17 @@ import {
STOOL_CONDITIONS,
TASTE_PREFERENCES,
} from '@/constants/review';
import { ACTION_TYPES, useReviewForm } from '@/hooks/review/useReviewForm';
import { REVIEW_ACTION_TYPES } from '@/hooks/review/useReviewForm';
import { ReviewData } from '@/pages/Review/ReviewFormFunnel';

interface ReviewFormProps {
petFoodId: number;
rating: number;
isEditMode?: boolean;
reviewId?: number;
reviewData: ReviewData;
}

const ReviewForm = (reviewFormProps: ReviewFormProps) => {
const { petFoodId, rating, isEditMode = false, reviewId = -1 } = reviewFormProps;

const { review, reviewDispatch, onSubmitReview, isValidComment } = useReviewForm({
petFoodId,
rating,
isEditMode,
reviewId,
});
const ReviewForm = (props: ReviewFormProps) => {
const {
reviewData: { review, isValidComment, reviewDispatch, onSubmitReview },
} = props;

return (
<ReviewFormContainer onSubmit={onSubmitReview}>
Expand All @@ -41,7 +34,7 @@ const ReviewForm = (reviewFormProps: ReviewFormProps) => {
aria-checked={review.tastePreference === text}
onClick={() => {
reviewDispatch({
type: ACTION_TYPES.SET_TASTE_PREFERENCE,
type: REVIEW_ACTION_TYPES.SET_TASTE_PREFERENCE,
tastePreference: text,
});
}}
Expand All @@ -62,7 +55,7 @@ const ReviewForm = (reviewFormProps: ReviewFormProps) => {
aria-checked={review.stoolCondition === text}
onClick={() => {
reviewDispatch({
type: ACTION_TYPES.SET_STOOL_CONDITION,
type: REVIEW_ACTION_TYPES.SET_STOOL_CONDITION,
stoolCondition: text,
});
}}
Expand All @@ -83,7 +76,7 @@ const ReviewForm = (reviewFormProps: ReviewFormProps) => {
aria-checked={review.adverseReactions.includes(text)}
onClick={() => {
reviewDispatch({
type: ACTION_TYPES.SET_ADVERSE_REACTIONS,
type: REVIEW_ACTION_TYPES.SET_ADVERSE_REACTIONS,
adverseReaction: text,
});
}}
Expand All @@ -99,7 +92,7 @@ const ReviewForm = (reviewFormProps: ReviewFormProps) => {
value={review.comment}
$isValid={isValidComment}
onChange={e => {
reviewDispatch({ type: ACTION_TYPES.SET_COMMENT, comment: e.target.value });
reviewDispatch({ type: REVIEW_ACTION_TYPES.SET_COMMENT, comment: e.target.value });
}}
/>
<ErrorCaption aria-live="assertive">
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/Review/ReviewItem/ReviewItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ const ReviewItem = (reviewItemProps: ReviewItemProps) => {
const { toggleHelpfulReaction } = useToggleHelpfulReactionMutation(reacted);

const onClickEditButton = () => {
navigate(routerPath.reviewStarRating({ petFoodId }), {
navigate(routerPath.reviewAddition({ petFoodId }), {
state: {
selectedRating: rating,
isEditMode: true,
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/Review/ReviewList/ReviewList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const ReviewList = () => {

const userInfo = zipgoLocalStorage.getUserInfo();

const goReviewWrite = () => navigate(routerPath.reviewStarRating({ petFoodId }));
const goReviewWrite = () => navigate(routerPath.reviewAddition({ petFoodId }));

useEffect(() => {
refetch();
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/constants/common.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
const FORM_EXIT_CONFIRMATION_MESSAGE =
'페이지를 떠나면 현재까지 입력한 정보가 사라질 수 있어요. 뒤로 가시겠어요?';

export { FORM_EXIT_CONFIRMATION_MESSAGE };
Loading

0 comments on commit e3240ef

Please sign in to comment.