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

feat-fe: 공고폼 임시 저장 기능 구현 #954

Merged
merged 15 commits into from
Dec 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ export default function Recruitment({ recruitmentInfoState, setRecruitmentInfoSt
const startDateText = startDate ? formatDate(startDate) : '';
const endDateText = endDate ? formatDate(endDate) : '';

const { register, errors } = useForm<RecruitmentInfoState>({
initialValues: { title: '', startDate: '', endDate: '', postingContent: '' },
const { register, errors } = useForm<Pick<RecruitmentInfoState, 'title'>>({
initialValues: { title: recruitmentInfoState.title },
});

useEffect(() => {
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/components/recruitmentPost/ApplyForm/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,15 @@ export default function ApplyForm({ questions, isClosed }: ApplyFormProps) {
const { data: recruitmentPost } = applyQueries.useGetRecruitmentPost({ applyFormId: applyFormId ?? '' });
const { mutate: apply } = applyMutations.useApply(applyFormId, recruitmentPost?.title ?? '');

// TODO: useForm은 input으로 initialValues제공해야 한다. 따라서 SideEffect를 피하기 위해선 useForm외부에서 localStorage를 별도로 저장해야 한다.
const {
formData: applicant,
register,
hasErrors,
} = useForm<ApplicantData>({
initialValues: { name: '', email: '', phone: '' },
});

const { answers, changeHandler, isRequiredFieldsIncomplete } = useAnswers(questions);
const [personalDataCollection, setPersonalDataCollection] = useState(false);

Expand Down
48 changes: 42 additions & 6 deletions frontend/src/hooks/useDashboardCreateForm/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import dashboardApis from '@api/domain/dashboard';
import { DEFAULT_QUESTIONS } from '@constants/constants';
import type { Question, QuestionOptionValue, RecruitmentInfoState, StepState } from '@customTypes/dashboard';
import useClubId from '@hooks/service/useClubId';
import useLocalStorageState from '@hooks/useLocalStorageState';
import { useMutation, UseMutationResult } from '@tanstack/react-query';
import { useState } from 'react';

Expand Down Expand Up @@ -40,14 +41,49 @@ const initialRecruitmentInfoState: RecruitmentInfoState = {
};

export default function useDashboardCreateForm(): UseDashboardCreateFormReturn {
const [stepState, setStepState] = useState<StepState>('recruitmentForm');
const [recruitmentInfoState, setRecruitmentInfoState] = useState<RecruitmentInfoState>(initialRecruitmentInfoState);
const [applyState, setApplyState] = useState<Question[]>(DEFAULT_QUESTIONS);
const clubId = useClubId().getClubId() || '';
const LOCALSTORAGE_KEYS = {
STEP: `${clubId}-step`,
INFO: `${clubId}-info`,
APPLY: `${clubId}-apply`,
} as const;

const [enableStorage] = useState(() => {
const Step = window.localStorage.getItem(LOCALSTORAGE_KEYS.STEP);
const Info = window.localStorage.getItem(LOCALSTORAGE_KEYS.INFO);
const Apply = window.localStorage.getItem(LOCALSTORAGE_KEYS.APPLY);

if (Step || Info || Apply) {
return window.confirm('이전 작성중인 공고가 있습니다. 이어서 진행하시겠습니까?');
}
return false;
});

const resetStorage = () => {
window.localStorage.removeItem(LOCALSTORAGE_KEYS.STEP);
window.localStorage.removeItem(LOCALSTORAGE_KEYS.INFO);
window.localStorage.removeItem(LOCALSTORAGE_KEYS.APPLY);
};

const [stepState, setStepState] = useLocalStorageState<StepState>('recruitmentForm', {
key: LOCALSTORAGE_KEYS.STEP,
enableStorage,
});
const [recruitmentInfoState, setRecruitmentInfoState] = useLocalStorageState<RecruitmentInfoState>(
initialRecruitmentInfoState,
{
key: LOCALSTORAGE_KEYS.INFO,
enableStorage,
},
);
const [applyState, setApplyState] = useLocalStorageState<Question[]>(DEFAULT_QUESTIONS, {
key: LOCALSTORAGE_KEYS.APPLY,
enableStorage,
});

const [finishResJson, setFinishResJson] = useState<FinishResJson | null>(null);
const [uniqueId, setUniqueId] = useState(DEFAULT_QUESTIONS.length);

const clubId = useClubId().getClubId() || '';

const submitMutator = useMutation({
mutationFn: () =>
dashboardApis.create({
Expand All @@ -62,7 +98,7 @@ export default function useDashboardCreateForm(): UseDashboardCreateFormReturn {
}),
onSuccess: async (data) => {
setStepState('finished');
// TODO: Suspence 작업 해야함.
resetStorage();
setFinishResJson(data);
},
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@ jest.mock('react-router-dom', () => ({
}));

describe('useDashboardCreateForm', () => {
beforeAll(() => {
beforeEach(() => {
localStorage.setItem('clubId', '1');
});

afterAll(() => {
localStorage.removeItem('clubId');
afterEach(() => {
localStorage.clear();
jest.restoreAllMocks();
});

const createWrapper = () => {
Expand Down Expand Up @@ -76,21 +77,23 @@ describe('useDashboardCreateForm', () => {
expect(result.current.applyState).toEqual(initialQuestions);
});

it('인덱스가 1에서 4인 질문은 prev할 수 없다.', () => {
it('인덱스가 0에서 3인 질문은 prev할 수 없다.', () => {
const { result } = renderHook(() => useDashboardCreateForm(), { wrapper: createWrapper() });
const initialQuestions = result.current.applyState;

act(() => result.current.addQuestion());
const expectQuestions = result.current.applyState;

act(() => {
result.current.setQuestionPrev(0)();
result.current.setQuestionPrev(1)();
result.current.setQuestionPrev(2)();
result.current.setQuestionPrev(3)();
result.current.setQuestionPrev(4)();
});

expect(result.current.applyState).toEqual(initialQuestions);
expect(result.current.applyState).toEqual(expectQuestions);
});

it('deleteQuestion을 호출하면 인덱스가 3 이상인 경우에만 해당 인덱스의 질문이 삭제된다.', () => {
it('deleteQuestion을 호출하면 인덱스가 3 이상인 경우에만 해당 인덱스의 질문이 삭제된다.', async () => {
const { result } = renderHook(() => useDashboardCreateForm(), { wrapper: createWrapper() });

// 인덱스가 3 미만인 경우 삭제되지 않아야 한다.
Expand All @@ -110,4 +113,98 @@ describe('useDashboardCreateForm', () => {

expect(result.current.applyState).toHaveLength(3);
});

it('localStorage에 값이 있으면 confirm을 1번 띄운다.', () => {
jest.spyOn(window, 'confirm').mockImplementation(() => true);

// Arrange
localStorage.setItem('1-step', 'recruitmentForm');
localStorage.setItem(
'1-info',
JSON.stringify({
startDate: '2024-12-01',
endDate: '2024-12-31',
title: 'Test Title',
postingContent: 'This is a test content',
}),
);

const { result } = renderHook(() => useDashboardCreateForm(), { wrapper: createWrapper() });

// Act
expect(window.confirm).toHaveBeenCalledTimes(1);

// Assert
expect(result.current.stepState).toBe('recruitmentForm');
expect(result.current.recruitmentInfoState).toEqual({
startDate: '2024-12-01',
endDate: '2024-12-31',
title: 'Test Title',
postingContent: 'This is a test content',
});
});

it('필수 정보를 채운 후 새로고침 시 localStorage를 복원하고 공고를 성공적으로 생성한다.', async () => {
jest.spyOn(window, 'confirm').mockImplementation(() => true);

const { result, rerender } = renderHook(() => useDashboardCreateForm(), { wrapper: createWrapper() });

// [1]Act
act(() => {
result.current.setRecruitmentInfoState((prev) => ({
...prev,
startDate: '2024-12-01',
endDate: '2024-12-31',
title: 'Test Title',
postingContent: 'This is a test content.',
}));
result.current.nextStep();
result.current.addQuestion();
});
act(() => result.current.setQuestionTitle(3)('Test Question'));

// [1]Assert
expect(result.current.stepState).toBe('applyForm');

// [2]Arrange
rerender(); // 새로고침과 같은 역할

expect(result.current.stepState).toBe('applyForm');
expect(result.current.recruitmentInfoState).toEqual({
startDate: '2024-12-01',
endDate: '2024-12-31',
title: 'Test Title',
postingContent: 'This is a test content.',
});

// [2]Act
await act(async () => {
result.current.nextStep();
});

// [2]Assert
expect(result.current.submitMutator.isSuccess).toBe(true);
});

it('confirm 입력으로 false를 입력하면 localStorage가 비워진다.', () => {
jest.spyOn(window, 'confirm').mockImplementation(() => false);

localStorage.setItem('1-step', 'recruitmentForm');
localStorage.setItem(
'1-info',
JSON.stringify({
startDate: '2024-12-01',
endDate: '2024-12-31',
title: 'Test Title',
postingContent: 'This is a test content',
}),
);
localStorage.setItem('1-apply', JSON.stringify([{ question: 'Test Question', type: 'SHORT_ANSWER' }]));

renderHook(() => useDashboardCreateForm(), { wrapper: createWrapper() });

expect(localStorage.getItem('1-step')).toBeNull();
expect(localStorage.getItem('1-info')).toBeNull();
expect(localStorage.getItem('1-apply')).toBeNull();
});
});
8 changes: 7 additions & 1 deletion frontend/src/hooks/useLocalStorageState/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';

interface OptionProp {
key: string;
Expand Down Expand Up @@ -36,6 +36,12 @@ const safeStringifyJSON = <T>(value: T): string | null => {
function useLocalStorageState<T>(initialValue: T, option: OptionProp): [T, (value: T | ((prev: T) => T)) => void] {
const { key, enableStorage = true } = option;

useEffect(() => {
if (!enableStorage) {
localStorage.removeItem(key);
}
}, []);

const [state, _setState] = useState<T>(() => {
if (!enableStorage) return initialValue;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ describe('useLocalStorageState의 값이 원시값인 경우에 대한 테스트
const { result } = renderHook(() => useLocalStorageState(0, { key: 'primitiveKey', enableStorage: false }));

expect(result.current[0]).toBe(0);
expect(window.localStorage.getItem('primitiveKey')).toBe('10');
expect(window.localStorage.getItem('primitiveKey')).toBeNull();
});
});

Expand Down Expand Up @@ -116,7 +116,7 @@ describe('useLocalStorageState의 값이 원시값인 경우에 대한 테스트
);

expect(result.current[0]).toEqual(initialObject);
expect(window.localStorage.getItem('objectKey')).toBe(storedObject);
expect(window.localStorage.getItem('objectKey')).toBeNull();
});
});
});
2 changes: 1 addition & 1 deletion frontend/src/mocks/handlers/dashboardHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const dashboardHandlers = [
const clubId = url.searchParams.get('clubId');
const body = (await request.json()) as DashboardFormInfo;

if (!body.startDate || !body.endDate || !body.postingContent || !body.title || clubId) {
if (!body.startDate || !body.endDate || !body.postingContent || !body.title || !clubId) {
return new Response(null, {
status: 400,
statusText: 'The request body is missing required information.',
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/router/AppRouter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ const RecruitmentPost = lazy(() => import(/* webpackChunkName: "RecruitmentPost"
const ConfirmApply = lazy(() => import(/* webpackChunkName: "SignConfirmApplyUp" */ '@pages/ConfirmApply'));
const DashboardLayout = lazy(() => import(/* webpackChunkName: "DashboardLayout" */ '@pages/DashboardLayout'));
const DashboardList = lazy(() => import(/* webpackChunkName: "DashBoardList" */ '@pages/DashBoardList'));
const DashboardCreate = lazy(() => import(/* webpackChunkName: "DashboardCreate" */ '@pages/DashboardCreate'));
// const DashboardCreate = lazy(() => import(/* webpackChunkName: "DashboardCreate" */ '@pages/DashboardCreate'));
// eslint-disable-next-line import/first, prettier/prettier, no-trailing-spaces, import/order
import DashboardCreate from '@pages/DashboardCreate'; // TODO: [lurgi] - 24.12.24 중복 렌더링에 따른 동적 임포트 임시 제거

const router = createBrowserRouter(
[
Expand Down
Loading