Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
c14b7be
fix: PollOptionListSection에서 리스트부분만 담당하도록 수정
KimDongGyun23 Jan 10, 2026
468f46f
fix: 모달 컴포넌트의 접근성 개선 및 스타일 수정
KimDongGyun23 Jan 10, 2026
6e926c8
feat: 공용 폼 유효성 검사 클래스 추가
KimDongGyun23 Jan 10, 2026
3b90afe
feat: 투표 폼 유효성 검사 클래스 및 제약 조건 추가
KimDongGyun23 Jan 10, 2026
c405130
feat: 투표 생성 모달에 폼 제출 및 유효성 검사 기능 추가
KimDongGyun23 Jan 10, 2026
2ce498c
feat: FormValidator에 대한 유효성 검사 단위 테스트 추가
KimDongGyun23 Jan 10, 2026
e350028
feat: 단위 테스트 추가 및 수정된 부분 반영
KimDongGyun23 Jan 10, 2026
d49fa2d
fix: CreatePollModal 및 Modal 컴포넌트의 레이아웃 개선
KimDongGyun23 Jan 10, 2026
8d11747
feat: CreatePollModal에서 폼 제출 및 모달 닫을 시 기존 폼 초기화
KimDongGyun23 Jan 10, 2026
fab5436
feat: FormField 컴포넌트 추가
KimDongGyun23 Jan 10, 2026
9e06fe4
refactor: CreatePollModal에서 FormField 컴포넌트로 폼 섹션 리팩토링
KimDongGyun23 Jan 10, 2026
1ebd53c
feat: FormField 테스트 추가 및 기존 테스트 파일 수정
KimDongGyun23 Jan 10, 2026
c84a5b1
chore: react-hook-form, zod 설치
KimDongGyun23 Jan 11, 2026
02babac
feat: 모달 컴포넌트에 제목 및 닫기 버튼 추가
KimDongGyun23 Jan 11, 2026
552272b
feat: FormFieldInput 및 Input 컴포넌트를 forwardRef로 변경하여 ref 지원 추가
KimDongGyun23 Jan 11, 2026
9dcd244
feat: 기존 폼 스키마를 zod를 활용하여 개선
KimDongGyun23 Jan 11, 2026
fa824dd
feat: usePollOptions 훅 및 테스트 파일 삭제
KimDongGyun23 Jan 11, 2026
a7993cf
feat: PollOptionItem 및 PollOptionList 컴포넌트 수정하여 react-hook-form 통합
KimDongGyun23 Jan 11, 2026
df7f650
feat: 투표 폼 컴포넌트 추가
KimDongGyun23 Jan 11, 2026
4a5d3b9
feat: 투표 생성 및 수정 모달 구현
KimDongGyun23 Jan 11, 2026
d48d159
fix: 불필요한 파일 제거
KimDongGyun23 Jan 11, 2026
19030d0
feat: 폼 스키마를 schema.ts 파일로 이름 변경
KimDongGyun23 Jan 11, 2026
1a35e8e
feat: vitest에 svgr 설정 추가
KimDongGyun23 Jan 11, 2026
afb39ed
test: 추가 및 수정된 컴포넌트 테스트 코드 작성
KimDongGyun23 Jan 11, 2026
f5e53bb
fix: 투표 모달 내부가 컨테이너를 벗어나 보이는 오류 수정
KimDongGyun23 Jan 11, 2026
9a2becd
fix: CreatePollModal 테스트에 '@testing-library/jest-dom' 추가
KimDongGyun23 Jan 11, 2026
5ecd746
feat: PollForm에서 폼 키를 상수로 정의하고 사용하도록 수정
KimDongGyun23 Jan 11, 2026
b11c173
fix: pollFormSchema에서 제목 필드에 trim() 메서드 추가
KimDongGyun23 Jan 11, 2026
35de57f
refactor: FormField 컴포넌트 구조 개선
KimDongGyun23 Jan 12, 2026
4f92628
refactor: FormField 테스트에서 Label을 Legend로 변경
KimDongGyun23 Jan 12, 2026
84eeb9a
refactor: FormField에서 Label을 Legend로 변경
KimDongGyun23 Jan 12, 2026
ecf1fd6
Merge branch 'develop' into feat/#61-poll-modal
KimDongGyun23 Jan 12, 2026
c12f997
fix: 이전 PR 병합으로, 불필요한 공통 파일 제거 및 불러오기 수정
KimDongGyun23 Jan 12, 2026
a8582c9
feat: 모달 컴포넌트에 제목 및 닫기 버튼 추가
KimDongGyun23 Jan 12, 2026
45e0a30
test: 테스트 파일에 jest-dom 불러오기 추가
KimDongGyun23 Jan 12, 2026
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
3 changes: 3 additions & 0 deletions apps/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"test:e2e": "playwright test"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@plum/shared-interfaces": "workspace:*",
"@sentry/react": "^10.32.1",
"@tailwindcss/vite": "^4.1.18",
Expand All @@ -23,10 +24,12 @@
"clsx": "^2.1.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.71.0",
"react-router": "^7.11.0",
"socket.io-client": "^4.8.1",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"zod": "^4.3.5",
"zustand": "^5.0.9"
},
"devDependencies": {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom';
import { describe, it, expect, beforeEach, vi } from 'vitest';

import { CreatePollModal } from './CreatePollModal';

describe('CreatePollModal', () => {
const user = userEvent.setup();
const mockOnCreate = vi.fn();
const mockOnClose = vi.fn();

beforeEach(() => {
vi.clearAllMocks();
});

it('사용자가 유효한 데이터를 입력하고 "추가하기"를 누르면 onCreate가 호출되고 폼이 초기화되어야 한다', async () => {
render(
<CreatePollModal
isOpen={true}
onClose={mockOnClose}
onCreate={mockOnCreate}
/>,
);

const titleInput = screen.getByPlaceholderText(/무엇을 묻고 싶으신가요/i);
await user.type(titleInput, '새로운 테스트 투표');

const optionInputs = screen.getAllByPlaceholderText(/선택지 \d/);
await user.type(optionInputs[0], '사과');
await user.type(optionInputs[1], '바나나');

const submitButton = screen.getByRole('button', { name: /추가하기/i });
await user.click(submitButton);

await waitFor(() => {
expect(mockOnCreate).toHaveBeenCalledWith({
title: '새로운 테스트 투표',
options: [{ value: '사과' }, { value: '바나나' }],
timeLimit: 0,
});
});

expect(mockOnClose).toHaveBeenCalled();

expect(titleInput).toHaveValue('');
});

it('필수 입력값이 누락되었을 때 "추가하기" 버튼이 비활성화 상태여야 한다', async () => {
render(
<CreatePollModal
isOpen={true}
onClose={mockOnClose}
onCreate={mockOnCreate}
/>,
);

await user.type(screen.getByPlaceholderText(/무엇을 묻고 싶으신가요/i), '제목만 있음');

const submitButton = screen.getByRole('button', { name: /추가하기/i });
expect(submitButton).toBeDisabled();
});

it('닫기 버튼 클릭 시 onClose가 호출되어야 한다', async () => {
render(
<CreatePollModal
isOpen={true}
onClose={mockOnClose}
onCreate={mockOnCreate}
/>,
);

const closeButton = screen.getByRole('button', { name: /모달 닫기/i });
await user.click(closeButton);

expect(mockOnClose).toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -1,35 +1,53 @@
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { Modal } from '@/shared/components/Modal';
import { PollOptionListSection } from './PollOptionListSection';
import { usePollOptions } from '../hooks/usePollOptions';
import { PollForm } from './PollForm';
import { pollFormDefaultValues, PollFormValues, pollFormSchema } from '../schema';
import { logger } from '@/shared/lib/logger';

interface CreatePollModalProps {
isOpen: boolean;
onClose: () => void;
onCreate: (data: PollFormValues) => void;
}

/**
* 투표 생성 모달 컴포넌트
* @param isOpen 모달 열림 상태
* @param onClose 모달 닫기 핸들러
* @param isOpen - 모달 열림 상태
* @param onClose - 모달 닫기 핸들러
* @param onCreate - 투표 추가 핸들러
* @returns 투표 생성 모달 JSX 요소
*/
export const CreatePollModal = ({ isOpen, onClose }: CreatePollModalProps) => {
const { options, addOption, deleteOption, updateOption, canAddMore, canDelete } =
usePollOptions();
export function CreatePollModal({ isOpen, onClose, onCreate }: CreatePollModalProps) {
const formMethods = useForm<PollFormValues>({
resolver: zodResolver(pollFormSchema),
defaultValues: pollFormDefaultValues,
mode: 'onChange',
});

const handleSubmit = (data: PollFormValues) => {
logger.ui.info('투표 생성 데이터:', data);
onCreate(data);
formMethods.reset();
onClose();
};

return (
<Modal
isOpen={isOpen}
onClose={onClose}
className="w-full max-w-181.5"
className="max-w-181.5"
>
<PollOptionListSection
options={options}
onAddOption={addOption}
onDeleteOption={deleteOption}
onUpdateOption={updateOption}
canAddMore={canAddMore}
canDelete={canDelete}
<header className="flex items-center justify-between pb-4">
<Modal.Title>새로운 투표 추가</Modal.Title>
<Modal.CloseButton onClose={onClose} />
</header>

<PollForm
formMethods={formMethods}
onSubmit={handleSubmit}
submitLabel="추가하기"
/>
</Modal>
);
};
}
122 changes: 122 additions & 0 deletions apps/frontend/src/feature/create-poll/components/PollForm.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import '@testing-library/jest-dom';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { PollForm } from './PollForm';
import { pollFormSchema, PollFormValues } from '../schema';
import { MAX_POLL_OPTIONS } from '../constants';

const TestWrapper = ({ onSubmit }: { onSubmit: (data: PollFormValues) => void }) => {
const formMethods = useForm<PollFormValues>({
resolver: zodResolver(pollFormSchema),
mode: 'onChange',
defaultValues: {
title: '',
options: [{ value: '' }, { value: '' }],
timeLimit: 0,
},
});

return (
<PollForm
formMethods={formMethods}
onSubmit={onSubmit}
submitLabel="제출하기"
/>
);
};

describe('PollForm', () => {
const user = userEvent.setup();
const mockSubmit = vi.fn();

beforeEach(() => {
mockSubmit.mockClear();
});

it('모든 필수 항목이 올바르게 입력되면 제출 버튼이 활성화되어야 한다', async () => {
render(<TestWrapper onSubmit={mockSubmit} />);
const submitButton = screen.getByRole('button', { name: '제출하기' });
expect(submitButton).toBeDisabled();

await user.type(screen.getByPlaceholderText(/무엇을 묻고 싶으신가요/), '오늘의 점심');
await user.type(screen.getByPlaceholderText('선택지 1'), '짜장면');
await user.type(screen.getByPlaceholderText('선택지 2'), '짬뽕');

await waitFor(() => {
expect(submitButton).toBeEnabled();
});
});

it('선택지 추가 버튼을 누르면 새로운 입력창이 나타나야 한다', async () => {
render(<TestWrapper onSubmit={mockSubmit} />);

const addButton = screen.getByRole('button', { name: /선택지 추가/ });
await user.click(addButton);

expect(screen.getByPlaceholderText('선택지 3')).toBeInTheDocument();
});

it(`선택지가 최대 개수(${MAX_POLL_OPTIONS})에 도달하면 추가 버튼이 비활성화되어야 한다`, async () => {
render(<TestWrapper onSubmit={mockSubmit} />);
const addButton = screen.getByRole('button', { name: /선택지 추가/ });

for (let i = 0; i < MAX_POLL_OPTIONS - 2; i++) {
await user.click(addButton);
}

expect(addButton).toBeDisabled();
});

it('제목에 공백만 입력할 경우, 다른 필드가 유효하더라도 제출 버튼이 비활성화되어야 한다', async () => {
render(<TestWrapper onSubmit={mockSubmit} />);
const submitButton = screen.getByRole('button', { name: '제출하기' });
const titleInput = screen.getByPlaceholderText(/무엇을 묻고 싶으신가요/);
const optionInputs = screen.getAllByPlaceholderText(/선택지 \d/);

await user.type(optionInputs[0], '사과');
await user.type(optionInputs[1], '바나나');

await user.type(titleInput, ' ');

await waitFor(() => {
expect(submitButton).toBeDisabled();
});
});

it('제출 시 입력된 데이터가 올바른 구조로 onSubmit 핸들러에 전달되어야 한다', async () => {
render(<TestWrapper onSubmit={mockSubmit} />);

await user.type(screen.getByPlaceholderText(/무엇을 묻고 싶으신가요/), '간식 투표');
await user.type(screen.getByPlaceholderText('선택지 1'), '치킨');
await user.type(screen.getByPlaceholderText('선택지 2'), '피자');

const submitButton = screen.getByRole('button', { name: '제출하기' });
await user.click(submitButton);

await waitFor(() => {
expect(mockSubmit).toHaveBeenCalledWith(
{
title: '간식 투표',
options: [{ value: '치킨' }, { value: '피자' }],
timeLimit: 0,
},
expect.anything(),
);
});
});

it('커스텀 드롭다운을 통해 시간이 변경되면 폼 상태에 반영되어야 한다', async () => {
render(<TestWrapper onSubmit={mockSubmit} />);

const dropdownButton = screen.getByRole('button', { name: /제한 없음/ });
await user.click(dropdownButton);

const option1Min = screen.getByText('1분');
await user.click(option1Min);

expect(screen.getByText('1분')).toBeInTheDocument();
});
});
95 changes: 95 additions & 0 deletions apps/frontend/src/feature/create-poll/components/PollForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { Controller, useFieldArray, UseFormReturn } from 'react-hook-form';
import { FormField } from '@/shared/components/FormField';
import { Button } from '@/shared/components/Button';
import { Icon } from '@/shared/components/icon/Icon';
import { PollOptionList } from './PollOptionList';
import { MAX_POLL_OPTIONS } from '../constants';
import { POLL_FORM_KEYS, PollFormValues } from '../schema';
import { TimeLimitDropdown } from '@/shared/components/TimeLimitDropdown';

interface PollFormProps {
formMethods: UseFormReturn<PollFormValues>;
onSubmit: (data: PollFormValues) => void;
submitLabel: string;
}

/**
* 투표 폼 컴포넌트
* @param formMethods - react-hook-form의 폼 메서드 객체
* @param onSubmit - 폼 제출 핸들러
* @param submitLabel - 제출 버튼 라벨
* @returns 투표 폼 JSX 요소
*/
export function PollForm({ formMethods, onSubmit, submitLabel }: PollFormProps) {
const {
register,
control,
handleSubmit,
formState: { isValid },
} = formMethods;

const { fields, append, remove } = useFieldArray({
control,
name: POLL_FORM_KEYS.options,
});

return (
<form
className="flex h-full min-h-0 flex-col gap-6 overflow-y-scroll"
onSubmit={handleSubmit(onSubmit)}
>
<FormField required>
<FormField.Legend className="mb-2 font-extrabold">투표 제목</FormField.Legend>
<FormField.Input
{...register(POLL_FORM_KEYS.title, { required: true, minLength: 2 })}
placeholder="무엇을 묻고 싶으신가요?"
/>
</FormField>

<FormField required>
<FormField.Legend className="mb-2 font-extrabold">투표 선택지</FormField.Legend>
<div className="flex flex-col gap-3">
<PollOptionList
fields={fields}
register={register}
onDelete={remove}
/>
<Button
variant="ghost"
type="button"
onClick={() => append({ value: '' })}
disabled={fields.length >= MAX_POLL_OPTIONS}
>
<Icon
name="plus"
size={14}
/>
<span>선택지 추가</span>
</Button>
</div>
</FormField>

<FormField required>
<FormField.Legend className="mb-2 font-extrabold">제한 시간</FormField.Legend>
<Controller
control={control}
name={POLL_FORM_KEYS.timeLimit}
render={({ field: { onChange, value } }) => (
<TimeLimitDropdown
selectedTime={value}
onChange={onChange}
/>
)}
/>
</FormField>

<Button
type="submit"
disabled={!isValid}
className="w-full"
>
{submitLabel}
</Button>
</form>
);
}
Loading
Loading