diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs index dd825e3f4..9825392e2 100644 --- a/frontend/.eslintrc.cjs +++ b/frontend/.eslintrc.cjs @@ -56,6 +56,17 @@ module.exports = { }, }, ], + '@typescript-eslint/naming-convention': [ + 'error', + { + selector: 'interface', + format: ['PascalCase'], + }, + { + selector: 'typeAlias', + format: ['PascalCase'], + }, + ], }, settings: { 'import/resolver': { diff --git a/frontend/src/components/highlight/components/HighlightEditor/hooks/useEditableState.ts b/frontend/src/components/highlight/components/HighlightEditor/hooks/useEditableState.ts index b08eb1f77..be573f3be 100644 --- a/frontend/src/components/highlight/components/HighlightEditor/hooks/useEditableState.ts +++ b/frontend/src/components/highlight/components/HighlightEditor/hooks/useEditableState.ts @@ -1,4 +1,4 @@ -import { useEffect, useLayoutEffect, useState } from 'react'; +import { useLayoutEffect, useState } from 'react'; import { HIGHLIGHT_EVENT_NAME, LOCAL_STORAGE_KEY } from '@/constants'; import { trackEventInAmplitude } from '@/utils'; diff --git a/frontend/src/constants/storageKey.ts b/frontend/src/constants/storageKey.ts index 5fb04d984..c5750874a 100644 --- a/frontend/src/constants/storageKey.ts +++ b/frontend/src/constants/storageKey.ts @@ -1,3 +1,10 @@ +export const STORED_DATA_NAME = { + selectedCategories :'selectedCategories', + answerValidations: 'answerValidations', + answers: 'answers', + visitedCardIdList: 'visitedCardIdList' +} as const; + export const LOCAL_STORAGE_KEY = { isHighlightEditable: 'isHighlightEditable', isHighlightError: 'isHighlightError', diff --git a/frontend/src/hooks/modal/useModals.ts b/frontend/src/hooks/modal/useModals.ts index d10113453..f5d82ead7 100644 --- a/frontend/src/hooks/modal/useModals.ts +++ b/frontend/src/hooks/modal/useModals.ts @@ -1,11 +1,13 @@ import { useState } from 'react'; -interface Modals { - [key: string]: boolean; +export type Modals = Record; + +interface UseModalsProps { + initialStates?: Modals; } -const useModals = () => { - const [modals, setModals] = useState({}); +const useModals = ({ initialStates }: UseModalsProps = {}) => { + const [modals, setModals] = useState(initialStates ?? {}); const openModal = (key: string) => { setModals((prev) => ({ @@ -21,7 +23,7 @@ const useModals = () => { })); }; - const isOpen = (key: string) => modals[key]; + const isOpen = (key: string) => !!modals[key]; return { isOpen, openModal, closeModal }; }; diff --git a/frontend/src/pages/ReviewWritingPage/constants/modal.ts b/frontend/src/pages/ReviewWritingPage/constants/modal.ts index 7ddda454e..601bb1f37 100644 --- a/frontend/src/pages/ReviewWritingPage/constants/modal.ts +++ b/frontend/src/pages/ReviewWritingPage/constants/modal.ts @@ -3,4 +3,5 @@ export const CARD_FORM_MODAL_KEY = { navigateConfirm: 'NAVIGATE_CONFIRM', recheck: 'RECHECK', submitError: 'SUBMIT_ERROR', + restoreConfirm: 'RESTORE_CONFIRM', }; diff --git a/frontend/src/pages/ReviewWritingPage/form/components/CardForm/index.tsx b/frontend/src/pages/ReviewWritingPage/form/components/CardForm/index.tsx index 158441881..e5593a0aa 100644 --- a/frontend/src/pages/ReviewWritingPage/form/components/CardForm/index.tsx +++ b/frontend/src/pages/ReviewWritingPage/form/components/CardForm/index.tsx @@ -1,21 +1,19 @@ import { useEffect } from 'react'; -import { useSetRecoilState } from 'recoil'; import { useSearchParamAndQuery } from '@/hooks'; -import { CARD_FORM_MODAL_KEY } from '@/pages/ReviewWritingPage/constants'; import { useCurrentCardIndex, useResetFormRecoil, useUpdateDefaultAnswers, - useNavigateBlocker, useLoadAndPrepareReview, + useSaveReviewToLocalStorage, + useRestoreFromLocalStorage, } from '@/pages/ReviewWritingPage/form/hooks'; import { CardFormModalContainer } from '@/pages/ReviewWritingPage/modals/components'; import useCardFormModal from '@/pages/ReviewWritingPage/modals/hooks/useCardFormModal'; import MobileProgressBar from '@/pages/ReviewWritingPage/progressBar/components/MobileProgressBar'; import ProgressBar from '@/pages/ReviewWritingPage/progressBar/components/ProgressBar'; import { CardSlider } from '@/pages/ReviewWritingPage/slider/components'; -import { reviewRequestCodeAtom } from '@/recoil'; import { calculateParticle } from '@/utils'; import * as S from './styles'; @@ -25,39 +23,24 @@ const CardForm = () => { paramKey: 'reviewRequestCode', }); - const setReviewRequestCode = useSetRecoilState(reviewRequestCodeAtom); - + const { resetFormRecoil } = useResetFormRecoil(); const { currentCardIndex, handleCurrentCardIndex } = useCurrentCardIndex(); - // 리뷰에 필요한 질문지,프로젝트 정보 가져오기 + // 로컬 스토리지에 저장된 값을 기반으로 모달의 isOpen 여부 설정 + const { restoreData, initialModalsState } = useRestoreFromLocalStorage(); + const { handleOpenModal, closeModal, isOpen } = useCardFormModal({ initialStates: initialModalsState }); + + // 프로젝트 정보 및 질문지를 서버에서 가져옴 const { revieweeName, projectName } = useLoadAndPrepareReview({ reviewRequestCode }); - // 답변 + // 생성된 질문지를 바탕으로 답변 기본값 및 답변의 유효성 기본값 설정 useUpdateDefaultAnswers(); - // 모달 - const { handleOpenModal, closeModal, isOpen } = useCardFormModal(); - - const handleNavigateConfirmButtonClick = () => { - closeModal(CARD_FORM_MODAL_KEY.navigateConfirm); - - if (blocker.proceed) blocker.proceed(); - }; - - // 작성 중인 답변이 있는 경우 페이지 이동을 막는 기능 - const { blocker } = useNavigateBlocker({ - openNavigateConfirmModal: () => handleOpenModal('navigateConfirm'), - }); - - const { resetFormRecoil } = useResetFormRecoil(); - - useEffect(() => { - if (reviewRequestCode) setReviewRequestCode(reviewRequestCode); - }, [reviewRequestCode]); + useSaveReviewToLocalStorage(); useEffect(() => { return () => { - // 페이지 나갈때 관련 recoil 상태 초기화 + // 페이지 나갈 때 관련 recoil 상태 초기화 resetFormRecoil(); }; }, []); @@ -67,6 +50,8 @@ const CardForm = () => { particles: { withFinalConsonant: '을', withoutFinalConsonant: '를' }, })} 리뷰해주세요!`; + const handleRestoreAnswers = () => restoreData(); + return ( @@ -91,11 +76,7 @@ const CardForm = () => { handleOpenModal={handleOpenModal} /> - + ); }; diff --git a/frontend/src/pages/ReviewWritingPage/form/hooks/answers/index.ts b/frontend/src/pages/ReviewWritingPage/form/hooks/answers/index.ts index 1d1ce3d5d..b5e237cd1 100644 --- a/frontend/src/pages/ReviewWritingPage/form/hooks/answers/index.ts +++ b/frontend/src/pages/ReviewWritingPage/form/hooks/answers/index.ts @@ -3,3 +3,6 @@ export { default as useTextAnswer } from './useTextAnswer'; export { default as useUpdateDefaultAnswers } from './useUpdateDefaultAnswers'; export { default as useUpdateReviewerAnswer } from './useUpdateReviewerAnswer'; export { default as useSubmitAnswers } from './useSubmitAnswers'; +export { default as useDeleteReviewInLocalStorage } from './useDeleteReviewInLocalStorage'; +export { default as useSaveReviewToLocalStorage } from './useSaveReviewToLocalStorage'; +export { default as useRestoreFromLocalStorage } from './useRestoreReviewFromLocalStorage'; diff --git a/frontend/src/pages/ReviewWritingPage/form/hooks/answers/multipleChoice/useMultipleChoice.ts b/frontend/src/pages/ReviewWritingPage/form/hooks/answers/multipleChoice/useMultipleChoice.ts index f5f7986e1..348999cca 100644 --- a/frontend/src/pages/ReviewWritingPage/form/hooks/answers/multipleChoice/useMultipleChoice.ts +++ b/frontend/src/pages/ReviewWritingPage/form/hooks/answers/multipleChoice/useMultipleChoice.ts @@ -1,4 +1,8 @@ -import { ReviewWritingCardQuestion } from '@/types'; +import { useEffect } from 'react'; +import { useRecoilValue } from 'recoil'; + +import { answerMapAtom } from '@/recoil'; +import { ReviewWritingAnswer, ReviewWritingCardQuestion } from '@/types'; import useAboveSelectionLimit from './useAboveSelectionLimit'; import useCheckTailQuestionAnswer from './useCheckTailQuestionAnswer'; @@ -16,7 +20,9 @@ interface UseMultipleChoiceProps { const useMultipleChoice = ({ question, handleModalOpen }: UseMultipleChoiceProps) => { const { isAnsweredTailQuestion } = useCheckTailQuestionAnswer({ question }); - const { selectedOptionList, isSelectedCheckbox, updateSelectedOptionList } = useOptionSelection(); + const { selectedOptionList, isSelectedCheckbox, updateSelectedOptionList, initSelectedOptionList } = + useOptionSelection(); + const answerMap = useRecoilValue(answerMapAtom); const { updateAnswerState } = useUpdateMultipleChoiceAnswer({ question }); @@ -34,6 +40,30 @@ const useMultipleChoice = ({ question, handleModalOpen }: UseMultipleChoiceProps }, ); + interface FindSelectedOptionIdsParams { + answerMap: Map | null; + questionId: number; + } + + // 로컬 스토리지에 저장했던 답변으로부터, questionId를 통해 해당 질문의 selectedOptionIds를 찾는 함수 + const findSelectedOptionIds = ({ answerMap, questionId }: FindSelectedOptionIdsParams) => { + if (!answerMap) return null; + + const selectedItem = answerMap.get(questionId); + return selectedItem ? selectedItem.selectedOptionIds : null; + }; + + // 저장된 객관식 답변이 있다면 복원 + useEffect(() => { + if (!answerMap || answerMap.size === 0) return; + if (selectedOptionList.length > 0) return; + + const questionId = question.questionId; + const selectedOptionIds = findSelectedOptionIds({ answerMap, questionId }); + + if (selectedOptionIds) initSelectedOptionList([...selectedOptionIds]); + }, [answerMap, question]); + const handleCheckboxChange = (event: React.ChangeEvent) => { const { id, checked } = event.currentTarget; const optionId = Number(id); @@ -88,4 +118,5 @@ const useMultipleChoice = ({ question, handleModalOpen }: UseMultipleChoiceProps unCheckCategoryOptionId, }; }; + export default useMultipleChoice; diff --git a/frontend/src/pages/ReviewWritingPage/form/hooks/answers/multipleChoice/useOptionSelection.ts b/frontend/src/pages/ReviewWritingPage/form/hooks/answers/multipleChoice/useOptionSelection.ts index 65e836602..927275084 100644 --- a/frontend/src/pages/ReviewWritingPage/form/hooks/answers/multipleChoice/useOptionSelection.ts +++ b/frontend/src/pages/ReviewWritingPage/form/hooks/answers/multipleChoice/useOptionSelection.ts @@ -15,6 +15,10 @@ const useOptionSelection = () => { checked: boolean; } + const initSelectedOptionList = (newSelectedOptionList: number[]) => { + setSelectedOptionList(newSelectedOptionList); + }; + /** * checkbox의 change 이벤트에 따라 새로운 selectedOptionList를 반환하는 함수 */ @@ -37,6 +41,7 @@ const useOptionSelection = () => { selectedOptionList, isSelectedCheckbox, updateSelectedOptionList, + initSelectedOptionList, }; }; diff --git a/frontend/src/pages/ReviewWritingPage/form/hooks/answers/useDeleteReviewInLocalStorage.ts b/frontend/src/pages/ReviewWritingPage/form/hooks/answers/useDeleteReviewInLocalStorage.ts new file mode 100644 index 000000000..8d3d99dfe --- /dev/null +++ b/frontend/src/pages/ReviewWritingPage/form/hooks/answers/useDeleteReviewInLocalStorage.ts @@ -0,0 +1,25 @@ +import { STORED_DATA_NAME } from '@/constants'; +import { useSearchParamAndQuery } from '@/hooks'; + +const useDeleteReviewInLocalStorage = () => { + const { param: reviewRequestCode } = useSearchParamAndQuery({ + paramKey: 'reviewRequestCode', + }); + + const deleteReviewDataInLocalStorage = (key: keyof typeof STORED_DATA_NAME) => { + localStorage.removeItem(`${STORED_DATA_NAME[key]}_${reviewRequestCode}`); + }; + + const deleteAllReviewDataInLocalStorage = () => { + Object.values(STORED_DATA_NAME).forEach((key) => { + localStorage.removeItem(`${STORED_DATA_NAME[key]}_${reviewRequestCode}`); + }); + }; + + return { + deleteReviewDataInLocalStorage, + deleteAllReviewDataInLocalStorage, + }; +}; + +export default useDeleteReviewInLocalStorage; diff --git a/frontend/src/pages/ReviewWritingPage/form/hooks/answers/useRestoreReviewFromLocalStorage.ts b/frontend/src/pages/ReviewWritingPage/form/hooks/answers/useRestoreReviewFromLocalStorage.ts new file mode 100644 index 000000000..dc7e2aba8 --- /dev/null +++ b/frontend/src/pages/ReviewWritingPage/form/hooks/answers/useRestoreReviewFromLocalStorage.ts @@ -0,0 +1,44 @@ +import { useCallback } from 'react'; +import { useSetRecoilState } from 'recoil'; + +import { STORED_DATA_NAME } from '@/constants'; +import { useSearchParamAndQuery } from '@/hooks'; +import { CARD_FORM_MODAL_KEY } from '@/pages/ReviewWritingPage/constants'; +import { selectedCategoryAtom, answerMapAtom, answerValidationMapAtom } from '@/recoil'; + +const useRestoreFromLocalStorage = () => { + const setSelectedCategory = useSetRecoilState(selectedCategoryAtom); + const setAnswerMap = useSetRecoilState(answerMapAtom); + const setAnswerValidation = useSetRecoilState(answerValidationMapAtom); + + const { param: reviewRequestCode } = useSearchParamAndQuery({ + paramKey: 'reviewRequestCode', + }); + + const initialModalsState = { + [CARD_FORM_MODAL_KEY.restoreConfirm]: + !!localStorage.getItem(`${STORED_DATA_NAME.selectedCategories}_${reviewRequestCode}`) || + !!localStorage.getItem(`${STORED_DATA_NAME.answers}_${reviewRequestCode}`) || + !!localStorage.getItem(`${STORED_DATA_NAME.answerValidations}_${reviewRequestCode}`), + }; + + const restoreData = useCallback(() => { + const storedSelectedCategories = localStorage.getItem( + `${STORED_DATA_NAME.selectedCategories}_${reviewRequestCode}`, + ); + const storedAnswerValidations = localStorage.getItem(`${STORED_DATA_NAME.answerValidations}_${reviewRequestCode}`); + const storedAnswers = localStorage.getItem(`${STORED_DATA_NAME.answers}_${reviewRequestCode}`); + + const selectedCategories = storedSelectedCategories ? JSON.parse(storedSelectedCategories) : null; + const answerValidations = storedAnswerValidations ? new Map(JSON.parse(storedAnswerValidations)) : new Map(); + const answers = storedAnswers ? JSON.parse(storedAnswers) : null; + + setSelectedCategory(selectedCategories); + setAnswerValidation(answerValidations); + setAnswerMap(new Map(answers)); + }, [reviewRequestCode, setSelectedCategory, setAnswerMap, setAnswerValidation]); + + return { restoreData, initialModalsState }; +}; + +export default useRestoreFromLocalStorage; diff --git a/frontend/src/pages/ReviewWritingPage/form/hooks/answers/useSaveReviewToLocalStorage.ts b/frontend/src/pages/ReviewWritingPage/form/hooks/answers/useSaveReviewToLocalStorage.ts new file mode 100644 index 000000000..939697488 --- /dev/null +++ b/frontend/src/pages/ReviewWritingPage/form/hooks/answers/useSaveReviewToLocalStorage.ts @@ -0,0 +1,70 @@ +import { useCallback, useEffect } from 'react'; +import { useRecoilValue } from 'recoil'; + +import { STORED_DATA_NAME } from '@/constants'; +import { useSearchParamAndQuery } from '@/hooks'; +import { answerMapAtom, answerValidationMapAtom, selectedCategoryAtom } from '@/recoil'; + +/** + * 리뷰와 관련된 데이터들을 실시간으로 로컬 스토리지에 저장하는 훅 + */ +const useSaveReviewToLocalStorage = () => { + const selectedCategory = useRecoilValue(selectedCategoryAtom); + const answerMap = useRecoilValue(answerMapAtom); + const answerValidation = useRecoilValue(answerValidationMapAtom); + + const { param: reviewRequestCode } = useSearchParamAndQuery({ + paramKey: 'reviewRequestCode', + }); + + const getCurrentSelectedCategory = useCallback(() => { + if (!selectedCategory || selectedCategory.length === 0) return null; + + return selectedCategory; + }, [selectedCategory]); + + const getCurrentAnswerValidation = useCallback(() => { + if (!answerValidation || answerValidation.size === 0) return null; + + const plainObjectAnswers = Array.from(answerValidation.entries()); + return plainObjectAnswers.length > 0 ? plainObjectAnswers : null; + }, [answerValidation]); + + const getCurrentAnswers = useCallback(() => { + if (!answerMap || answerMap.size === 0) return null; + + const plainObjectAnswers = Array.from(answerMap.entries()); + return plainObjectAnswers.length > 0 ? plainObjectAnswers : null; + }, [answerMap]); + + const saveToLocalStorage = useCallback(() => { + const selectedCategories = getCurrentSelectedCategory(); + const answers = getCurrentAnswers(); + const answerValidations = getCurrentAnswerValidation(); + + if (selectedCategories) { + localStorage.setItem( + `${STORED_DATA_NAME.selectedCategories}_${reviewRequestCode}`, + JSON.stringify(selectedCategories), + ); + } + + if (answerValidations) { + localStorage.setItem( + `${STORED_DATA_NAME.answerValidations}_${reviewRequestCode}`, + JSON.stringify(answerValidations), + ); + } + + if (answers) { + localStorage.setItem(`${STORED_DATA_NAME.answers}_${reviewRequestCode}`, JSON.stringify(answers)); + } + }, [getCurrentSelectedCategory, getCurrentAnswers, getCurrentAnswerValidation, reviewRequestCode]); + + // 로컬 스토리지 동기화 + useEffect(() => { + saveToLocalStorage(); + }, [saveToLocalStorage]); +}; + +export default useSaveReviewToLocalStorage; diff --git a/frontend/src/pages/ReviewWritingPage/form/hooks/answers/useSubmitAnswers.ts b/frontend/src/pages/ReviewWritingPage/form/hooks/answers/useSubmitAnswers.ts index 9af130402..8ae04d451 100644 --- a/frontend/src/pages/ReviewWritingPage/form/hooks/answers/useSubmitAnswers.ts +++ b/frontend/src/pages/ReviewWritingPage/form/hooks/answers/useSubmitAnswers.ts @@ -7,6 +7,8 @@ import { ReviewWritingFormResult } from '@/types'; import useMutateReview from '../useMutateReview'; +import { useDeleteReviewInLocalStorage } from '.'; + interface UseSubmitAnswersProps { closeSubmitConfirmModal: () => void; } @@ -15,12 +17,13 @@ interface UseSubmitAnswersProps { */ const useSubmitAnswers = ({ closeSubmitConfirmModal }: UseSubmitAnswersProps) => { const reviewRequestCode = useRecoilValue(reviewRequestCodeAtom); - const answerMap = useRecoilValue(answerMapAtom); const navigate = useNavigate(); - + const { deleteAllReviewDataInLocalStorage } = useDeleteReviewInLocalStorage(); + const executeAfterMutateSuccess = () => { + deleteAllReviewDataInLocalStorage(); navigate(`/${ROUTE.reviewWritingComplete}/${reviewRequestCode}`, { state: { isValidAccess: true } }); closeSubmitConfirmModal(); }; @@ -33,7 +36,7 @@ const useSubmitAnswers = ({ closeSubmitConfirmModal }: UseSubmitAnswersProps) => if (!answerMap || !reviewRequestCode) return; const result: ReviewWritingFormResult = { - reviewRequestCode: reviewRequestCode, + reviewRequestCode, answers: Array.from(answerMap.values()), }; diff --git a/frontend/src/pages/ReviewWritingPage/form/hooks/answers/useTextAnswer/index.ts b/frontend/src/pages/ReviewWritingPage/form/hooks/answers/useTextAnswer/index.ts index 5afb5ec55..cbe81da50 100644 --- a/frontend/src/pages/ReviewWritingPage/form/hooks/answers/useTextAnswer/index.ts +++ b/frontend/src/pages/ReviewWritingPage/form/hooks/answers/useTextAnswer/index.ts @@ -1,6 +1,8 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; +import { useRecoilValue } from 'recoil'; import { TEXT_ANSWER_LENGTH } from '@/pages/ReviewWritingPage/constants'; +import { answerMapAtom } from '@/recoil'; import { ReviewWritingAnswer, ReviewWritingCardQuestion } from '@/types'; import useUpdateReviewerAnswer from '../useUpdateReviewerAnswer'; @@ -21,10 +23,39 @@ interface UseTextAnswerProps { */ const useTextAnswer = ({ question }: UseTextAnswerProps) => { const { updateAnswerMap, updateAnswerValidationMap } = useUpdateReviewerAnswer(); + const answerMap = useRecoilValue(answerMapAtom); const [text, setText] = useState(''); const [errorMessage, setErrorMessage] = useState(TEXT_ANSWER_ERROR_MESSAGE.noError); + // 로컬 스토리지에 저장했던 답변으로부터, questionId를 통해 해당 질문의 서술형 답변을 찾는 함수 + // TODO: 복원을 위한 find 함수들을 별도 유틸로 분리 및 통합 + interface FindTextAnswerParams { + answerMap: Map | null; + questionId: number; + } + const findTextAnswer = ({ answerMap, questionId }: FindTextAnswerParams) => { + if (!answerMap) return null; + + for (const [, value] of answerMap) { + if (value.questionId === questionId) { + return value.text; + } + } + return null; + }; + + // 저장된 주관식 답변이 있다면 복원 + useEffect(() => { + if (!answerMap || answerMap.size === 0) return; + if(text && text.length > 0) return; + + const questionId = question.questionId; + const textAnswer = findTextAnswer({ answerMap, questionId }); + + if (textAnswer) setText(textAnswer); + }, [answerMap, question]); + const handleTextAnswerChange = (event: React.ChangeEvent) => { const { value } = event.target; diff --git a/frontend/src/pages/ReviewWritingPage/form/hooks/answers/useUpdateDefaultAnswers.ts b/frontend/src/pages/ReviewWritingPage/form/hooks/answers/useUpdateDefaultAnswers.ts index 1b6efae67..a22112c94 100644 --- a/frontend/src/pages/ReviewWritingPage/form/hooks/answers/useUpdateDefaultAnswers.ts +++ b/frontend/src/pages/ReviewWritingPage/form/hooks/answers/useUpdateDefaultAnswers.ts @@ -12,9 +12,11 @@ const DEFAULT_VALUE = { * cardSectionListSelector(=리뷰 작성 페이지에서 리뷰이가 작성해야하는 질문지)가 변경되었을때, 이에 맞추어서 답변(answerMap)과 답변들의 유효성 여부(answerValidationMap)을 변경하는 훅 */ const useUpdateDefaultAnswers = () => { - const cardSectionList = useRecoilValue(cardSectionListSelector); // NOTE : answerMap - 질문에 대한 답변들 , number : questionId const [answerMap, setAnswerMap] = useRecoilState(answerMapAtom); + + // 서버에서 받아온 질문지 + const cardSectionList = useRecoilValue(cardSectionListSelector); // NOTE : answerValidationMap -질문의 단볍들의 유효성 여부 ,number: questionId const [answerValidationMap, setAnswerValidationMap] = useRecoilState(answerValidationMapAtom); /* NOTE: 질문 변경 시, answerMap 변경 케이스 정리 @@ -101,10 +103,14 @@ const useUpdateDefaultAnswers = () => { }; useEffect(() => { - const { newAnswerMap, newAnswerValidationMap } = makeNewAnswerAndValidationMaps(); - setAnswerMap(newAnswerMap); - setAnswerValidationMap(newAnswerValidationMap); - }, [cardSectionList]); + // answerMap이 비어 있을 때만 작성 페이지 초기화 작업 수행 + // : 작성한 내용이 아예 없는 상황에서 새로고침할 때, 기존 답변을 저장한 로컬 스토리지가 초기화되는 것을 막기 위함 + if (answerMap?.size === 0) { + const { newAnswerMap, newAnswerValidationMap } = makeNewAnswerAndValidationMaps(); + setAnswerMap(newAnswerMap); + setAnswerValidationMap(newAnswerValidationMap); + } + }, [cardSectionList, setAnswerMap, setAnswerValidationMap]); }; export default useUpdateDefaultAnswers; diff --git a/frontend/src/pages/ReviewWritingPage/form/hooks/useNavigateBlocker.ts b/frontend/src/pages/ReviewWritingPage/form/hooks/useNavigateBlocker.ts index d3bc57ab5..84a0616bd 100644 --- a/frontend/src/pages/ReviewWritingPage/form/hooks/useNavigateBlocker.ts +++ b/frontend/src/pages/ReviewWritingPage/form/hooks/useNavigateBlocker.ts @@ -16,11 +16,6 @@ const useNavigateBlocker = ({ openNavigateConfirmModal }: UseNavigateBlockerProp return [...answerMap.values()].some((answer) => !!answer.selectedOptionIds?.length || !!answer.text?.length); }; - // 페이지 새로고침 및 닫기에 대한 처리: 브라우저 기본 alert 등장 - const handleNavigationBlock = (event: BeforeUnloadEvent) => { - if (isAnswerInProgress()) event.preventDefault(); - }; - // 페이지 히스토리에 영향을 주는 페이지 이동 처리: useBlocker 이용 const blocker = useBlocker(({ currentLocation, nextLocation }) => { const isLeavingPage = currentLocation.pathname !== nextLocation.pathname; @@ -35,14 +30,6 @@ const useNavigateBlocker = ({ openNavigateConfirmModal }: UseNavigateBlockerProp } }, [blocker]); - useEffect(() => { - window.addEventListener('beforeunload', handleNavigationBlock); - - return () => { - window.removeEventListener('beforeunload', handleNavigationBlock); - }; - }, [answerMap]); - return { blocker, }; diff --git a/frontend/src/pages/ReviewWritingPage/modals/components/CardFormModalContainer/index.tsx b/frontend/src/pages/ReviewWritingPage/modals/components/CardFormModalContainer/index.tsx index 8832f2ac3..493f8dfe1 100644 --- a/frontend/src/pages/ReviewWritingPage/modals/components/CardFormModalContainer/index.tsx +++ b/frontend/src/pages/ReviewWritingPage/modals/components/CardFormModalContainer/index.tsx @@ -5,24 +5,19 @@ import { ErrorBoundary } from '@/components'; import { CARD_FORM_MODAL_KEY } from '@/pages/ReviewWritingPage/constants'; import { AnswerListRecheckModal, - NavigateBlockerModal, SubmitCheckModal, + SubmitErrorModal, + RestoreAnswerCheckModal, } from '@/pages/ReviewWritingPage/modals/components'; import { answerMapAtom, cardSectionListSelector } from '@/recoil'; -import SubmitErrorModal from '../SubmitErrorModal'; - interface CardFormModalContainerProps { isOpen: (key: string) => boolean; closeModal: (key: string) => void; - handleNavigateConfirmButtonClick: () => void; + handleRestoreButtonClick: () => void; } -const CardFormModalContainer = ({ - isOpen, - closeModal, - handleNavigateConfirmButtonClick, -}: CardFormModalContainerProps) => { +const CardFormModalContainer = ({ isOpen, closeModal, handleRestoreButtonClick }: CardFormModalContainerProps) => { const answerMap = useRecoilValue(answerMapAtom); const cardSectionList = useRecoilValue(cardSectionListSelector); @@ -48,7 +43,6 @@ const CardFormModalContainer = ({ )} - {isOpen(CARD_FORM_MODAL_KEY.recheck) && cardSectionList && answerMap && ( closeModal(CARD_FORM_MODAL_KEY.recheck)} /> )} - {isOpen(CARD_FORM_MODAL_KEY.navigateConfirm) && ( - closeModal(CARD_FORM_MODAL_KEY.navigateConfirm)} - handleCloseModal={() => closeModal(CARD_FORM_MODAL_KEY.navigateConfirm)} - /> + {isOpen(CARD_FORM_MODAL_KEY.restoreConfirm) && ( + closeModal(CARD_FORM_MODAL_KEY.restoreConfirm)} + > )} ); diff --git a/frontend/src/pages/ReviewWritingPage/modals/components/NavigateBlockerModal/index.tsx b/frontend/src/pages/ReviewWritingPage/modals/components/NavigateBlockerModal/index.tsx deleted file mode 100644 index 684b109e6..000000000 --- a/frontend/src/pages/ReviewWritingPage/modals/components/NavigateBlockerModal/index.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { ConfirmModal } from '@/components'; - -import * as S from './style'; - -interface NavigateBlockerModalProps { - handleNavigateConfirmButtonClick: () => void; - handleCancelButtonClick: () => void; - handleCloseModal: () => void; -} - -const NavigateBlockerModal = ({ - handleNavigateConfirmButtonClick, - handleCancelButtonClick, - handleCloseModal, -}: NavigateBlockerModalProps) => { - return ( - - -

페이지를 이동하면 작성한 답변이 삭제돼요

-

페이지 이동을 진행할까요?

-
-
- ); -}; - -export default NavigateBlockerModal; diff --git a/frontend/src/pages/ReviewWritingPage/modals/components/NavigateBlockerModal/style.ts b/frontend/src/pages/ReviewWritingPage/modals/components/NavigateBlockerModal/style.ts deleted file mode 100644 index 14d8246fb..000000000 --- a/frontend/src/pages/ReviewWritingPage/modals/components/NavigateBlockerModal/style.ts +++ /dev/null @@ -1,20 +0,0 @@ -import styled from '@emotion/styled'; - -import media from '@/utils/media'; - -export const ConfirmModalMessage = styled.div` - display: flex; - flex-direction: column; - gap: 0.8rem; - align-items: center; - - p { - margin: 0; - text-align: center; - } - - ${media.xSmall} { - width: 100%; - min-width: 70vw; - } -`; diff --git a/frontend/src/pages/ReviewWritingPage/modals/components/RestoreAnswerCheckModal/index.tsx b/frontend/src/pages/ReviewWritingPage/modals/components/RestoreAnswerCheckModal/index.tsx new file mode 100644 index 000000000..13701ad6e --- /dev/null +++ b/frontend/src/pages/ReviewWritingPage/modals/components/RestoreAnswerCheckModal/index.tsx @@ -0,0 +1,48 @@ +import { ConfirmModal } from '@/components'; +import { useDeleteReviewInLocalStorage } from '@/pages/ReviewWritingPage/form/hooks'; + +import * as S from './style'; + +interface SubmitCheckModalProps { + restoreAnswer: () => void; + closeModal: () => void; +} + +const RestoreAnswerCheckModal = ({ restoreAnswer, closeModal }: SubmitCheckModalProps) => { + const { deleteAllReviewDataInLocalStorage } = useDeleteReviewInLocalStorage(); + + const handleRestoreButtonClick = () => { + restoreAnswer(); + closeModal(); + }; + + const handleDeleteButtonClick = () => { + deleteAllReviewDataInLocalStorage(); + closeModal(); + }; + + return ( + + + 작성했던 리뷰가 있어요 +

진행 상황을 복원할까요?

+

삭제를 선택하면 기존에 작성했던 내용이 삭제돼요

+
+
+ ); +}; + +export default RestoreAnswerCheckModal; diff --git a/frontend/src/pages/ReviewWritingPage/modals/components/RestoreAnswerCheckModal/style.ts b/frontend/src/pages/ReviewWritingPage/modals/components/RestoreAnswerCheckModal/style.ts new file mode 100644 index 000000000..a3efc5306 --- /dev/null +++ b/frontend/src/pages/ReviewWritingPage/modals/components/RestoreAnswerCheckModal/style.ts @@ -0,0 +1,26 @@ +import styled from '@emotion/styled'; + +import media from '@/utils/media'; + +export const RestoreAnswerCheckModal = styled.div` + display: flex; + flex-direction: column; + gap: 1rem; + align-items: center; + + width: max-content; + p { + width: fit-content; + text-align: center; + } + + ${media.xSmall} { + width: 23rem; + } +`; + +export const ConfirmModalTitle = styled.p` + margin-bottom: 1rem; + font-size: ${({ theme }) => theme.fontSize.medium}; + font-weight: ${({ theme }) => theme.fontWeight.bold}; +`; diff --git a/frontend/src/pages/ReviewWritingPage/modals/components/index.tsx b/frontend/src/pages/ReviewWritingPage/modals/components/index.tsx index f4a21430b..e5a3eaa2d 100644 --- a/frontend/src/pages/ReviewWritingPage/modals/components/index.tsx +++ b/frontend/src/pages/ReviewWritingPage/modals/components/index.tsx @@ -1,5 +1,6 @@ export { default as AnswerListRecheckModal } from './AnswerListRecheckModal'; export { default as CardFormModalContainer } from './CardFormModalContainer'; -export { default as NavigateBlockerModal } from './NavigateBlockerModal'; export { default as SubmitCheckModal } from './SubmitCheckModal'; export { default as StrengthUnCheckModal } from './StrengthUnCheckModal'; +export { default as RestoreAnswerCheckModal } from './RestoreAnswerCheckModal'; +export { default as SubmitErrorModal } from './SubmitErrorModal'; diff --git a/frontend/src/pages/ReviewWritingPage/modals/hooks/useCardFormModal.ts b/frontend/src/pages/ReviewWritingPage/modals/hooks/useCardFormModal.ts index a7e3a53ce..fea77f018 100644 --- a/frontend/src/pages/ReviewWritingPage/modals/hooks/useCardFormModal.ts +++ b/frontend/src/pages/ReviewWritingPage/modals/hooks/useCardFormModal.ts @@ -1,9 +1,14 @@ import { useModals } from '@/hooks'; +import { Modals } from '@/hooks/modal/useModals'; import { CARD_FORM_MODAL_KEY } from '../../constants'; -const useCardFormModal = () => { - const { isOpen, openModal, closeModal } = useModals(); +interface UseCardFormModalProps { + initialStates: Modals; +} + +const useCardFormModal = ({ initialStates }: UseCardFormModalProps) => { + const { isOpen, openModal, closeModal } = useModals({ initialStates }); const handleOpenModal = (key: keyof typeof CARD_FORM_MODAL_KEY) => { openModal(CARD_FORM_MODAL_KEY[key]); diff --git a/frontend/src/pages/ReviewWritingPage/progressBar/hooks/useStepList/index.ts b/frontend/src/pages/ReviewWritingPage/progressBar/hooks/useStepList/index.ts index c7669682c..94e1ffcf9 100644 --- a/frontend/src/pages/ReviewWritingPage/progressBar/hooks/useStepList/index.ts +++ b/frontend/src/pages/ReviewWritingPage/progressBar/hooks/useStepList/index.ts @@ -1,6 +1,8 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { useRecoilValue } from 'recoil'; +import { STORED_DATA_NAME } from '@/constants'; +import { useSearchParamAndQuery } from '@/hooks'; import { answerValidationMapAtom, cardSectionListSelector } from '@/recoil'; interface UseStepListProps { @@ -64,7 +66,38 @@ const useStepList = ({ currentCardIndex }: UseStepListProps) => { }); }; + // 복원 및 저장 로직 + const { param: reviewRequestCode } = useSearchParamAndQuery({ + paramKey: 'reviewRequestCode', + }); + + const storeVisitedCardIdList = useCallback(() => { + if (visitedCardIdList.length === 0) return; + + localStorage.setItem( + `${STORED_DATA_NAME.visitedCardIdList}_${reviewRequestCode}`, + JSON.stringify(visitedCardIdList), + ); + }, [reviewRequestCode, visitedCardIdList]); + + // 복원 + useEffect(() => { + if (cardSectionList.length === 0 || !reviewRequestCode) return; + + const storedVisitedCardIdList = localStorage.getItem(`${STORED_DATA_NAME.visitedCardIdList}_${reviewRequestCode}`); + const parsedVisitedCardIdList = storedVisitedCardIdList ? JSON.parse(storedVisitedCardIdList) : []; + + setVisitedCardIdList(parsedVisitedCardIdList); + }, [reviewRequestCode, cardSectionList]); + + // 로컬 스토리지와의 동기화를 위한 useEffect useEffect(() => { + storeVisitedCardIdList(); + }, [visitedCardIdList]); + + useEffect(() => { + if (cardSectionList.length === 0 || visitedCardIdList.length === 0) return; + updateVisitedCardIdList(); }, [cardSectionList, currentCardIndex]); diff --git a/frontend/src/pages/ReviewWritingPage/progressBar/hooks/useStepList/test.tsx b/frontend/src/pages/ReviewWritingPage/progressBar/hooks/useStepList/test.tsx index 7217a17b8..f56143050 100644 --- a/frontend/src/pages/ReviewWritingPage/progressBar/hooks/useStepList/test.tsx +++ b/frontend/src/pages/ReviewWritingPage/progressBar/hooks/useStepList/test.tsx @@ -1,4 +1,5 @@ import { renderHook, waitFor } from '@testing-library/react'; +import { BrowserRouter } from 'react-router-dom'; import { RecoilRoot, RecoilState, useRecoilValue, useSetRecoilState } from 'recoil'; import { REVIEW_QUESTION_DATA, STRENGTH_SECTION_LIST } from '@/mocks/mockData'; @@ -23,13 +24,15 @@ interface RenderUseStepListHookProps { const renderUseStepListHook = ({ currentCardIndex }: RenderUseStepListHookProps) => { const wrapper = ({ children }: EssentialPropsWithChildren) => ( - { - set(reviewWritingFormSectionListAtom, REVIEW_QUESTION_DATA.sections); - }} - > - {children} - + + { + set(reviewWritingFormSectionListAtom, REVIEW_QUESTION_DATA.sections); + }} + > + {children} + + ); return renderHook(