-
Notifications
You must be signed in to change notification settings - Fork 1
✨[Feat] Modal 컴포넌트, 프로필 수정 / 리뷰 작성 모달 개발 #141 #145
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
The head ref may contain hidden characters: "141-refactor-popup-\uCEF4\uD3EC\uB10C\uD2B8\uB97C-\uBAA8\uB2EC-\uAE30\uB2A5\uAE4C\uC9C0-\uD655\uC7A5"
Changes from all commits
d598412
b7125f3
9cde451
554d9fc
74e6b14
fdbc373
47fe3a1
a79dcf8
73a6532
b577686
038f7c1
b1ae509
5f0f647
3000eb5
540dcc3
a71846b
72b1981
923e032
91580e0
6254062
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,26 @@ | ||
| import { SVGProps } from 'react'; | ||
|
|
||
| interface EditIconProps extends SVGProps<SVGSVGElement> { | ||
| width?: number; | ||
| height?: number; | ||
| } | ||
|
|
||
| function EditIcon({ width = 7, height = 9, ...props }: EditIconProps) { | ||
| return ( | ||
| <svg | ||
| width={width} | ||
| height={height} | ||
| viewBox="0 0 7 9" | ||
| fill="none" | ||
| xmlns="http://www.w3.org/2000/svg" | ||
| {...props} | ||
| > | ||
| <path | ||
| d="M3.12284 0.602564C3.30417 0.199663 3.77368 0.0288589 4.17151 0.221063L5.71719 0.967829C6.11502 1.16003 6.29053 1.64246 6.10919 2.04536L3.37194 8.12719C3.28474 8.32093 3.12502 8.46995 2.92801 8.54138L1.80364 8.94904C1.39456 9.09737 0.939295 8.87741 0.786006 8.4574L0.364689 7.30297C0.290865 7.10069 0.298382 6.87813 0.385582 6.68439L3.12284 0.602564Z" | ||
| fill="currentColor" | ||
| /> | ||
| </svg> | ||
| ); | ||
| } | ||
|
|
||
| export default EditIcon; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| import type { Meta, StoryObj } from '@storybook/react'; | ||
| import Modal from './Modal'; | ||
|
|
||
| const meta: Meta<typeof Modal> = { | ||
| title: 'Components/Modal', | ||
| component: Modal, | ||
| parameters: { | ||
| layout: 'centered', | ||
| }, | ||
| tags: ['autodocs'], | ||
| argTypes: { | ||
| isOpen: { | ||
| control: 'boolean', | ||
| description: '모달의 표시 여부를 제어합니다', | ||
| }, | ||
| title: { | ||
| control: 'text', | ||
| description: '모달의 제목', | ||
| }, | ||
| onClose: { action: '닫기 클릭' }, | ||
| onConfirm: { action: '확인 클릭' }, | ||
| }, | ||
| }; | ||
|
|
||
| export default meta; | ||
| type Story = StoryObj<typeof Modal>; | ||
|
|
||
| export const Default: Story = { | ||
| args: { | ||
| isOpen: true, | ||
| title: '모달 제목', | ||
| cancelText: '취소', | ||
| confirmText: '확인', | ||
| children: '모달에 넣을 children', | ||
| onClose: () => console.log('닫기 클릭'), | ||
| onConfirm: () => console.log('확인 클릭'), | ||
| cancelButtonProps: { | ||
| themeColor: 'gray-dark-01', | ||
| lightColor: 'gray-normal-01', | ||
| fillType: 'lightSolid', | ||
| }, | ||
| confirmButtonProps: { | ||
| themeColor: 'green-normal-01', | ||
| lightColor: 'green-light-03', | ||
| fillType: 'lightSolid', | ||
| }, | ||
| }, | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| import '@testing-library/jest-dom'; | ||
| import { render, screen, fireEvent } from '@testing-library/react'; | ||
| import Modal from './Modal'; | ||
|
|
||
| describe('Modal 컴포넌트', () => { | ||
| const defaultProps = { | ||
| isOpen: true, | ||
| onClose: jest.fn(), | ||
| title: '테스트 모달', | ||
| onConfirm: jest.fn(), | ||
| cancelText: '취소', | ||
| confirmText: '확인', | ||
| children: <div>모달 내용</div>, | ||
| }; | ||
|
|
||
| beforeEach(() => { | ||
| jest.clearAllMocks(); | ||
| }); | ||
|
|
||
| it('isOpen이 true일 때 모달이 렌더링되어야 함', () => { | ||
| render(<Modal {...defaultProps} />); | ||
|
|
||
| expect(screen.getByText('테스트 모달')).toBeInTheDocument(); | ||
| expect(screen.getByText('모달 내용')).toBeInTheDocument(); | ||
| expect(screen.getByText('취소')).toBeInTheDocument(); | ||
| expect(screen.getByText('확인')).toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it('isOpen이 false일 때 모달이 렌더링되지 않아야 함', () => { | ||
| render(<Modal {...defaultProps} isOpen={false} />); | ||
|
|
||
| expect(screen.queryByText('테스트 모달')).not.toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it('닫기 버튼 클릭 시 onClose가 호출되어야 함', () => { | ||
| render(<Modal {...defaultProps} />); | ||
|
|
||
| const closeButton = screen.getByLabelText('닫기'); | ||
| fireEvent.click(closeButton); | ||
| expect(defaultProps.onClose).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it('취소 버튼 클릭 시 onClose가 호출되어야 함', () => { | ||
| render(<Modal {...defaultProps} />); | ||
|
|
||
| fireEvent.click(screen.getByText('취소')); | ||
| expect(defaultProps.onClose).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it('확인 버튼 클릭 시 onConfirm이 호출되어야 함', () => { | ||
| render(<Modal {...defaultProps} />); | ||
|
|
||
| fireEvent.click(screen.getByText('확인')); | ||
| expect(defaultProps.onConfirm).toHaveBeenCalledTimes(1); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| import React from 'react'; | ||
| import Button, { ButtonProps } from '../button/Button'; | ||
| import { IcClose } from '../../../public/icons'; | ||
|
|
||
| interface ModalProps { | ||
| isOpen: boolean; | ||
| onClose: () => void; | ||
| children: React.ReactNode; | ||
| title: string; | ||
| onConfirm: () => void; | ||
| cancelText: string; | ||
| confirmText: string; | ||
| cancelButtonProps?: Partial<ButtonProps>; | ||
| confirmButtonProps?: Partial<ButtonProps>; | ||
| } | ||
|
|
||
| function Modal({ | ||
| isOpen, | ||
| onClose, | ||
| children, | ||
| title, | ||
| onConfirm, | ||
| cancelText, | ||
| confirmText, | ||
| cancelButtonProps, | ||
| confirmButtonProps, | ||
| }: ModalProps) { | ||
| if (!isOpen) return null; | ||
|
|
||
| return ( | ||
| <div className="fixed inset-0 z-50 flex items-center justify-center"> | ||
| <div className="fixed inset-0 bg-black bg-opacity-50" onClick={onClose} /> | ||
|
|
||
| <div className="relative z-50 mx-4 max-h-[80vh] min-h-[200px] w-full min-w-[336px] max-w-[520px] overflow-y-auto rounded-lg bg-white p-6"> | ||
| <div className="mb-4 flex items-center justify-between"> | ||
| <h2 className="text-xl font-semibold">{title}</h2> | ||
| <button | ||
| onClick={onClose} | ||
| className="cursor-pointer hover:opacity-70" | ||
| aria-label="닫기" | ||
| > | ||
| <IcClose /> | ||
| </button> | ||
| </div> | ||
|
|
||
| <div className="mb-6">{children}</div> | ||
|
|
||
| <div className="flex justify-between gap-2"> | ||
| <Button | ||
| text={cancelText} | ||
| size="modal" | ||
| fillType="lightSolid" | ||
| themeColor="gray-normal-03" | ||
| onClick={onClose} | ||
| className="w-full" | ||
| {...cancelButtonProps} | ||
| /> | ||
| <Button | ||
| text={confirmText} | ||
| size="modal" | ||
| fillType="lightSolid" | ||
| themeColor="green-light-03" | ||
| onClick={onConfirm} | ||
| className="w-full" | ||
| {...confirmButtonProps} | ||
| /> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| export default Modal; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,118 @@ | ||
| import { useState } from 'react'; | ||
| import { useAuthStore } from '@/store/authStore'; | ||
| import Avatar from '@/components/avatar/Avatar'; | ||
| import EditIcon from '../../../../public/icons/EditIcon'; | ||
| import Modal from '@/components/modal/Modal'; | ||
|
|
||
| interface ProfileData { | ||
| name: string; | ||
| companyName: string; | ||
| image?: string | null; | ||
| } | ||
|
|
||
| interface ProfileEditModalProps { | ||
| isOpen: boolean; | ||
| onClose: () => void; | ||
| onConfirm: (updatedData: ProfileData) => void; | ||
| profileData: ProfileData; | ||
| } | ||
|
|
||
| function ProfileEditContent({ | ||
| formData, | ||
| handleChange, | ||
| }: { | ||
| formData: ProfileData; | ||
| handleChange: (e: React.ChangeEvent<HTMLInputElement>) => void; | ||
| }) { | ||
| return ( | ||
| <div className="w-full"> | ||
| <div className="flex flex-col items-center gap-4"> | ||
| <div className="relative"> | ||
| <div className="rounded-full border-2 border-gray-normal-01"> | ||
| <Avatar src={formData.image || ''} alt="프로필 이미지" size="xl" /> | ||
| </div> | ||
| <button | ||
|
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. input으로 이미지를 어떻게 추가할까요? <input type="file" id="imageInput" accept="image/*" />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. 인풋에 ref를 걸어서 ref current를 참고하면 버튼을 클릭해서 업로드를 실행시킬 수 있을 거에요. 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.
|
||
| className="absolute bottom-0 right-0 translate-x-1/3 rounded-full border border-gray-normal-01 bg-white p-2" | ||
| onClick={() => { | ||
| /* 이미지 업로드 로직 추가 필요 */ | ||
| console.log('이미지 업로드 로직 추가 필요'); | ||
| }} | ||
| > | ||
| <EditIcon width={14} height={18} className="text-gray-dark-01" /> | ||
| </button> | ||
| </div> | ||
|
|
||
| <div className="w-full space-y-4"> | ||
| <div className="flex flex-col gap-2"> | ||
| <span className="font-bold">닉네임</span> | ||
| <input | ||
| type="text" | ||
| name="name" | ||
| value={formData.name} | ||
| onChange={handleChange} | ||
| className="w-full rounded-lg bg-gray-light-02 p-2 font-medium" | ||
| /> | ||
| </div> | ||
| <div className="flex flex-col gap-2"> | ||
| <span className="font-bold">한 줄 소개</span> | ||
| <input | ||
| type="text" | ||
| name="companyName" | ||
| value={formData.companyName} | ||
| onChange={handleChange} | ||
| className="w-full rounded-lg bg-gray-light-02 p-2 font-medium" | ||
| /> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| function ProfileEditModal({ | ||
| isOpen, | ||
| onClose, | ||
| onConfirm, | ||
| profileData, | ||
| }: ProfileEditModalProps) { | ||
| const { user } = useAuthStore(); | ||
|
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. 페이지를 새로고침 후에, 어떻게 유저 정보가 계속 남는지 정리하기! |
||
| const [formData, setFormData] = useState<ProfileData>({ | ||
| name: profileData.name || user?.name || '', | ||
| companyName: profileData.companyName || user?.companyName || '', | ||
| image: profileData.image || user?.image || '/images/profile.png', | ||
| }); | ||
|
|
||
| const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { | ||
| const { name, value } = e.target; | ||
| setFormData((prev) => ({ ...prev, [name]: value })); | ||
| }; | ||
|
|
||
| const handleConfirm = () => { | ||
| onConfirm(formData); | ||
| }; | ||
|
|
||
| return ( | ||
| <Modal | ||
| isOpen={isOpen} | ||
| onClose={onClose} | ||
| title="프로필 수정하기" | ||
| onConfirm={handleConfirm} | ||
| cancelText="취소하기" | ||
| confirmText="수정하기" | ||
| cancelButtonProps={{ | ||
| themeColor: 'gray-dark-01', | ||
| lightColor: 'gray-normal-01', | ||
| fillType: 'lightSolid', | ||
| }} | ||
| confirmButtonProps={{ | ||
| themeColor: 'green-normal-01', | ||
| lightColor: 'green-light-03', | ||
| fillType: 'lightSolid', | ||
| }} | ||
| > | ||
| <ProfileEditContent formData={formData} handleChange={handleChange} /> | ||
| </Modal> | ||
| ); | ||
| } | ||
|
|
||
| export default ProfileEditModal; | ||
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.
p5: 이건 추후에 확인, 취소 등으로 디폴트값을 설정하고 앞으로 모달작업할 때 의미가 통할만한 부분은 취소, 확인으로 고정하는식으로 가도 좋을 것 같네요. 저번에 멘토님과 멘토링하면서 느꼈던건데 디자인도 중요하지만 저희가 어느정도 가볍게 갈 수 있는 부분은 계속 전달 드리는게 좋을 것 같다는 생각이 들었습니다!