diff --git a/.pnp.cjs b/.pnp.cjs index 33da7cf8..8876f91b 100755 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -80,6 +80,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["lodash", "npm:4.17.21"],\ ["lottie-web", "npm:5.12.2"],\ ["msw", "virtual:d08515d9e4b27cdfeb9b2dde224a8841bd05f954e612ea2dd8b5b9cd65297eea34fcb753f3a56527367eedd956e930ca8b2ba53a7468cc42d099c8cdf5472f02#npm:1.2.2"],\ + ["nanoid", "npm:4.0.2"],\ ["next", "virtual:d08515d9e4b27cdfeb9b2dde224a8841bd05f954e612ea2dd8b5b9cd65297eea34fcb753f3a56527367eedd956e930ca8b2ba53a7468cc42d099c8cdf5472f02#npm:13.4.12"],\ ["postcss", "npm:8.4.25"],\ ["prettier", "npm:2.8.8"],\ @@ -94,6 +95,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["typescript", "patch:typescript@npm%3A5.1.6#~builtin::version=5.1.6&hash=5da071"],\ ["vite", "virtual:d08515d9e4b27cdfeb9b2dde224a8841bd05f954e612ea2dd8b5b9cd65297eea34fcb753f3a56527367eedd956e930ca8b2ba53a7468cc42d099c8cdf5472f02#npm:4.4.2"],\ ["vitest", "virtual:d08515d9e4b27cdfeb9b2dde224a8841bd05f954e612ea2dd8b5b9cd65297eea34fcb753f3a56527367eedd956e930ca8b2ba53a7468cc42d099c8cdf5472f02#npm:0.31.4"],\ + ["vitest-canvas-mock", "virtual:d08515d9e4b27cdfeb9b2dde224a8841bd05f954e612ea2dd8b5b9cd65297eea34fcb753f3a56527367eedd956e930ca8b2ba53a7468cc42d099c8cdf5472f02#npm:0.3.2"],\ ["zod", "npm:3.21.4"],\ ["zustand", "virtual:d08515d9e4b27cdfeb9b2dde224a8841bd05f954e612ea2dd8b5b9cd65297eea34fcb753f3a56527367eedd956e930ca8b2ba53a7468cc42d099c8cdf5472f02#npm:4.3.9"]\ ],\ @@ -5876,6 +5878,15 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["cssfontparser", [\ + ["npm:1.2.1", {\ + "packageLocation": "./.yarn/cache/cssfontparser-npm-1.2.1-f48947509b-952d487cdd.zip/node_modules/cssfontparser/",\ + "packageDependencies": [\ + ["cssfontparser", "npm:1.2.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["cssstyle", [\ ["npm:3.0.0", {\ "packageLocation": "./.yarn/cache/cssstyle-npm-3.0.0-3947bec0b5-31f694dfed.zip/node_modules/cssstyle/",\ @@ -9006,6 +9017,17 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["jest-canvas-mock", [\ + ["npm:2.4.0", {\ + "packageLocation": "./.yarn/cache/jest-canvas-mock-npm-2.4.0-f993bb2728-feda3c9a33.zip/node_modules/jest-canvas-mock/",\ + "packageDependencies": [\ + ["jest-canvas-mock", "npm:2.4.0"],\ + ["cssfontparser", "npm:1.2.1"],\ + ["moo-color", "npm:1.0.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["jest-diff", [\ ["npm:29.6.1", {\ "packageLocation": "./.yarn/cache/jest-diff-npm-29.6.1-5db9e52cf2-c6350178ca.zip/node_modules/jest-diff/",\ @@ -9981,6 +10003,16 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["moo-color", [\ + ["npm:1.0.3", {\ + "packageLocation": "./.yarn/cache/moo-color-npm-1.0.3-50b8650764-02bf59b6bb.zip/node_modules/moo-color/",\ + "packageDependencies": [\ + ["moo-color", "npm:1.0.3"],\ + ["color-name", "npm:1.1.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["morgan", [\ ["npm:1.10.0", {\ "packageLocation": "./.yarn/cache/morgan-npm-1.10.0-a0da109b6b-fb41e226ab.zip/node_modules/morgan/",\ @@ -10075,6 +10107,13 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["nanoid", "npm:3.3.6"]\ ],\ "linkType": "HARD"\ + }],\ + ["npm:4.0.2", {\ + "packageLocation": "./.yarn/cache/nanoid-npm-4.0.2-ae010cad86-747c399cea.zip/node_modules/nanoid/",\ + "packageDependencies": [\ + ["nanoid", "npm:4.0.2"]\ + ],\ + "linkType": "HARD"\ }]\ ]],\ ["natural-compare", [\ @@ -13117,6 +13156,29 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["vitest-canvas-mock", [\ + ["npm:0.3.2", {\ + "packageLocation": "./.yarn/cache/vitest-canvas-mock-npm-0.3.2-647983e4de-ed564f94f4.zip/node_modules/vitest-canvas-mock/",\ + "packageDependencies": [\ + ["vitest-canvas-mock", "npm:0.3.2"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:d08515d9e4b27cdfeb9b2dde224a8841bd05f954e612ea2dd8b5b9cd65297eea34fcb753f3a56527367eedd956e930ca8b2ba53a7468cc42d099c8cdf5472f02#npm:0.3.2", {\ + "packageLocation": "./.yarn/__virtual__/vitest-canvas-mock-virtual-c8cad770e1/0/cache/vitest-canvas-mock-npm-0.3.2-647983e4de-ed564f94f4.zip/node_modules/vitest-canvas-mock/",\ + "packageDependencies": [\ + ["vitest-canvas-mock", "virtual:d08515d9e4b27cdfeb9b2dde224a8841bd05f954e612ea2dd8b5b9cd65297eea34fcb753f3a56527367eedd956e930ca8b2ba53a7468cc42d099c8cdf5472f02#npm:0.3.2"],\ + ["@types/vitest", null],\ + ["jest-canvas-mock", "npm:2.4.0"],\ + ["vitest", "virtual:d08515d9e4b27cdfeb9b2dde224a8841bd05f954e612ea2dd8b5b9cd65297eea34fcb753f3a56527367eedd956e930ca8b2ba53a7468cc42d099c8cdf5472f02#npm:0.31.4"]\ + ],\ + "packagePeers": [\ + "@types/vitest",\ + "vitest"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["w3c-xmlserializer", [\ ["npm:4.0.0", {\ "packageLocation": "./.yarn/cache/w3c-xmlserializer-npm-4.0.0-f09d0ec3fc-eba070e78d.zip/node_modules/w3c-xmlserializer/",\ @@ -13488,6 +13550,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["lodash", "npm:4.17.21"],\ ["lottie-web", "npm:5.12.2"],\ ["msw", "virtual:d08515d9e4b27cdfeb9b2dde224a8841bd05f954e612ea2dd8b5b9cd65297eea34fcb753f3a56527367eedd956e930ca8b2ba53a7468cc42d099c8cdf5472f02#npm:1.2.2"],\ + ["nanoid", "npm:4.0.2"],\ ["next", "virtual:d08515d9e4b27cdfeb9b2dde224a8841bd05f954e612ea2dd8b5b9cd65297eea34fcb753f3a56527367eedd956e930ca8b2ba53a7468cc42d099c8cdf5472f02#npm:13.4.12"],\ ["postcss", "npm:8.4.25"],\ ["prettier", "npm:2.8.8"],\ @@ -13502,6 +13565,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["typescript", "patch:typescript@npm%3A5.1.6#~builtin::version=5.1.6&hash=5da071"],\ ["vite", "virtual:d08515d9e4b27cdfeb9b2dde224a8841bd05f954e612ea2dd8b5b9cd65297eea34fcb753f3a56527367eedd956e930ca8b2ba53a7468cc42d099c8cdf5472f02#npm:4.4.2"],\ ["vitest", "virtual:d08515d9e4b27cdfeb9b2dde224a8841bd05f954e612ea2dd8b5b9cd65297eea34fcb753f3a56527367eedd956e930ca8b2ba53a7468cc42d099c8cdf5472f02#npm:0.31.4"],\ + ["vitest-canvas-mock", "virtual:d08515d9e4b27cdfeb9b2dde224a8841bd05f954e612ea2dd8b5b9cd65297eea34fcb753f3a56527367eedd956e930ca8b2ba53a7468cc42d099c8cdf5472f02#npm:0.3.2"],\ ["zod", "npm:3.21.4"],\ ["zustand", "virtual:d08515d9e4b27cdfeb9b2dde224a8841bd05f954e612ea2dd8b5b9cd65297eea34fcb753f3a56527367eedd956e930ca8b2ba53a7468cc42d099c8cdf5472f02#npm:4.3.9"]\ ],\ diff --git a/.yarn/cache/cssfontparser-npm-1.2.1-f48947509b-952d487cdd.zip b/.yarn/cache/cssfontparser-npm-1.2.1-f48947509b-952d487cdd.zip new file mode 100644 index 00000000..846d1d0d Binary files /dev/null and b/.yarn/cache/cssfontparser-npm-1.2.1-f48947509b-952d487cdd.zip differ diff --git a/.yarn/cache/jest-canvas-mock-npm-2.4.0-f993bb2728-feda3c9a33.zip b/.yarn/cache/jest-canvas-mock-npm-2.4.0-f993bb2728-feda3c9a33.zip new file mode 100644 index 00000000..70726053 Binary files /dev/null and b/.yarn/cache/jest-canvas-mock-npm-2.4.0-f993bb2728-feda3c9a33.zip differ diff --git a/.yarn/cache/moo-color-npm-1.0.3-50b8650764-02bf59b6bb.zip b/.yarn/cache/moo-color-npm-1.0.3-50b8650764-02bf59b6bb.zip new file mode 100644 index 00000000..ee2da696 Binary files /dev/null and b/.yarn/cache/moo-color-npm-1.0.3-50b8650764-02bf59b6bb.zip differ diff --git a/.yarn/cache/nanoid-npm-4.0.2-ae010cad86-747c399cea.zip b/.yarn/cache/nanoid-npm-4.0.2-ae010cad86-747c399cea.zip new file mode 100644 index 00000000..b3384e96 Binary files /dev/null and b/.yarn/cache/nanoid-npm-4.0.2-ae010cad86-747c399cea.zip differ diff --git a/.yarn/cache/vitest-canvas-mock-npm-0.3.2-647983e4de-ed564f94f4.zip b/.yarn/cache/vitest-canvas-mock-npm-0.3.2-647983e4de-ed564f94f4.zip new file mode 100644 index 00000000..8001c8fd Binary files /dev/null and b/.yarn/cache/vitest-canvas-mock-npm-0.3.2-647983e4de-ed564f94f4.zip differ diff --git a/package.json b/package.json index 2b0efc05..05a0990f 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "ky": "^0.33.3", "lodash": "^4.17.21", "lottie-web": "^5.12.2", + "nanoid": "^4.0.2", "next": "13.4.12", "postcss": "^8.4.23", "prop-types": "^15.8.1", @@ -50,6 +51,7 @@ "react-dom": "18.2.0", "react-hook-form": "^7.43.9", "typescript": "^5.1.3", + "vitest-canvas-mock": "^0.3.2", "zod": "^3.21.4", "zustand": "^4.3.8" }, diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 8bfffbf0..7c266ee1 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,6 +4,7 @@ import Script from 'next/script'; import { OverlayProvider, QueryProvider } from '@/providers'; import { ProfileProvider } from '@/providers/server'; import Head from 'next/head'; +import { RouteChangesProvider } from '@/app/write/contexts/RouteChangeProvider'; const pretendardFont = localFont({ src: [ @@ -98,7 +99,9 @@ export default function RootLayout({ children }: { children: React.ReactNode }) - {children} + + {children} + diff --git a/src/app/write/[id]/page.tsx b/src/app/write/[id]/page.tsx index 57da83ba..fa5697a3 100644 --- a/src/app/write/[id]/page.tsx +++ b/src/app/write/[id]/page.tsx @@ -1,7 +1,7 @@ import { notFound } from 'next/navigation'; import { InputLoading } from '../components'; import { Suspense } from 'react'; -import BoardForm from '../components/Form/BoardForm'; +import { BoardForm } from '@/app/write/components'; const BoardEditPage = async ({ params: { id }, diff --git a/src/app/write/components/Form/BoardForm.tsx b/src/app/write/components/Form/BoardForm.tsx index 095d48bf..b84b07e6 100644 --- a/src/app/write/components/Form/BoardForm.tsx +++ b/src/app/write/components/Form/BoardForm.tsx @@ -1,15 +1,13 @@ 'use client'; -import FirstStep from './FirstStep'; -import SecondStep from './SecondStep'; -import { wrapper } from '../../style.css'; +import { useEffect } from 'react'; +import { useParams, useRouter } from 'next/navigation'; import { useFunnel } from '@/hooks'; import { useProfile } from '@/api/hooks'; -import { useParams, useRouter } from 'next/navigation'; -import WriteTitle from '../WriteTitle/WriteTitle'; -import useSetFormData from '../../hooks/useSetFormData'; -import useFormStore from '../../store/useFormStore'; -import { useEffect } from 'react'; +import { FirstStep, SecondStep, WriteTitle } from '@/app/write/components'; +import { useSetFormData, useLeaveModal } from '@/app/write/hooks'; +import { wrapper } from '../../style.css'; +import useFormStore from '@/app/write/store/useFormStore'; export default function BoardForm() { const { data } = useProfile(); @@ -35,6 +33,7 @@ export default function BoardForm() { if (!data) { router.push('/login'); } + useLeaveModal(true); return (
diff --git a/src/app/write/components/Form/FirstStep.tsx b/src/app/write/components/Form/FirstStep.tsx index 43d3cd22..2fe30b4e 100644 --- a/src/app/write/components/Form/FirstStep.tsx +++ b/src/app/write/components/Form/FirstStep.tsx @@ -5,7 +5,7 @@ import { FormProvider } from 'react-hook-form'; import { Button, Input, InputSection, Typography } from '@/components'; import { InputDate, Counter, AgeModal, AgeBottomSheet, MapModal, TimeDropDown } from '@/app/write/components'; import { StepOneData } from '@/app/write/types'; -import { useWriteForm } from '@/app/write/hooks/useWriteForm'; +import { useWriteForm } from '@/app/write/hooks'; import { formWrapper, sectionGap, inputGap, submitButton, flexBetween, ageError } from './Form.css'; import { useIsMobile } from '@/hooks'; diff --git a/src/app/write/components/Form/SecondStep.tsx b/src/app/write/components/Form/SecondStep.tsx index 8ffff9dd..d9c01905 100644 --- a/src/app/write/components/Form/SecondStep.tsx +++ b/src/app/write/components/Form/SecondStep.tsx @@ -6,9 +6,10 @@ import { Button, Input, InputSection, TextArea, Toast, Typography } from '@/comp import { formWrapper, inputGap } from './Form.css'; import parseData from './util/parseData'; import { useRouter } from 'next/navigation'; -import { useWriteForm } from '@/app/write/hooks/useWriteForm'; +import { useWriteForm } from '@/app/write/hooks/'; import { useOverlay } from '@/hooks'; import { usePostBoard, usePatchBoard } from '@/api/write'; +import { FreezeModal } from '@/app/write/components'; type StepProps = { reset: () => void; @@ -21,6 +22,7 @@ const SecondStep = ({ reset, boardId, isPatch = false }: StepProps) => { const { mutate: patch } = usePatchBoard(boardId); const { stepTwoMethod } = useWriteForm(); const [openToast, closeToast] = useOverlay(); + const [openModal, closeModal] = useOverlay(); const router = useRouter(); const onSubmit: SubmitHandler = async (data: BoardData) => { @@ -46,21 +48,33 @@ const SecondStep = ({ reset, boardId, isPatch = false }: StepProps) => { ); } if (isPatch && boardId) { - patch( - { boardId: boardId, data: { ...parseData(data) } }, - { - onSuccess: () => { - openToast(); - router.push(`/board/${boardId}`); - reset(); + const freezeSubmit = () => { + closeModal(); + patch( + { boardId: boardId, data: { ...parseData(data) } }, + { + onSuccess: () => { + openToast(); + router.push(`/board/${boardId}`); + reset(); + }, + onError: (error) => { + openToast(); + if (error.response.status === 403) { + router.push('/login'); + } + }, }, - onError: (error) => { - openToast(); - if (error.response.status === 403) { - router.push('/login'); - } - }, - }, + ); + return; + }; + openModal( + , ); } }; diff --git a/src/app/write/components/FreezeModal/FreezeModal.tsx b/src/app/write/components/FreezeModal/FreezeModal.tsx new file mode 100644 index 00000000..303ab991 --- /dev/null +++ b/src/app/write/components/FreezeModal/FreezeModal.tsx @@ -0,0 +1,36 @@ +import { Button, Modal, Typography } from '@/components'; + +interface Props { + onClose: () => void; + onClick?: () => void; + content?: string; + footer?: string | string[]; +} + +const FreezeModal = ({ + content = '페이지를 나가면 작성 중인 먹팟이 삭제돼요.', + footer = ['나가기', '계속 작성하기'], + onClose, + onClick, +}: Props) => { + return ( + + + + + {content} + + + + + + + + ); +}; + +export default FreezeModal; diff --git a/src/app/write/components/index.ts b/src/app/write/components/index.ts index 09f0aa70..c78c88dc 100644 --- a/src/app/write/components/index.ts +++ b/src/app/write/components/index.ts @@ -4,7 +4,9 @@ export { default as Counter } from './Counter/Counter'; export { default as InputDate } from './InputDate/InputDate'; export { default as FirstStep } from './Form/FirstStep'; export { default as SecondStep } from './Form/SecondStep'; +export { default as BoardForm } from './Form/BoardForm'; export { default as MapModal } from './MapModal/MapModal'; export { default as WriteTitle } from './WriteTitle/WriteTitle'; export { default as TimeDropDown } from './TimeDropDown/TimeDropDown'; export { default as InputLoading } from './InputLoading/InputLoading'; +export { default as FreezeModal } from './FreezeModal/FreezeModal'; diff --git a/src/app/write/contexts/RouteChangeProvider.tsx b/src/app/write/contexts/RouteChangeProvider.tsx new file mode 100644 index 00000000..bd0ddaa4 --- /dev/null +++ b/src/app/write/contexts/RouteChangeProvider.tsx @@ -0,0 +1,219 @@ +'use client'; + +import React, { useContext, useEffect, useRef, useState } from 'react'; +import { nanoid } from 'nanoid'; + +type HistoryURL = string | URL | null | undefined; + +type RouteChangeStartEvent = CustomEvent<{ targetUrl: string }>; +type RouteChangeEndEvent = CustomEvent<{ targetUrl: HistoryURL }>; +type ForceAnchorClickEvent = MouseEvent & { isForceAnchorClickEvent: true }; + +declare global { + interface WindowEventMap { + beforeRouteChangeEvent: RouteChangeStartEvent; + routeChangeConfirmationEvent: RouteChangeStartEvent; + routeChangeStartEvent: RouteChangeStartEvent; + routeChangeEndEvent: RouteChangeEndEvent; + } +} + +interface FreezeRequestsContextValue { + freezeRequests: string[]; + setFreezeRequests: React.Dispatch>; +} + +const isServer = typeof window === 'undefined'; + +const FreezeRequestsContext = React.createContext({ + freezeRequests: [], + // eslint-disable-next-line @typescript-eslint/no-empty-function + setFreezeRequests: () => {}, +}); + +export const useFreezeRequestsContext = () => { + const { freezeRequests, setFreezeRequests } = useContext(FreezeRequestsContext); + + return { + freezeRequests, + request: (sourceId: string) => { + setFreezeRequests([...freezeRequests, sourceId]); + }, + revoke: (sourceId: string) => { + setFreezeRequests(freezeRequests.filter((x) => x !== sourceId)); + }, + }; +}; + +type PushStateInput = [data: unknown, unused: string, url: HistoryURL]; + +export const triggerRouteChangeStartEvent = (targetUrl: string): void => { + const ev = new CustomEvent('routeChangeStartEvent', { detail: { targetUrl } }); + if (!isServer) window.dispatchEvent(ev); +}; + +export const triggerRouteChangeEndEvent = (targetUrl: HistoryURL): void => { + const ev = new CustomEvent('routeChangeEndEvent', { detail: { targetUrl } }); + if (!isServer) window.dispatchEvent(ev); +}; + +export const triggerBeforeRouteChangeEvent = (targetUrl: string): void => { + const ev = new CustomEvent('beforeRouteChangeEvent', { detail: { targetUrl } }); + if (!isServer) window.dispatchEvent(ev); +}; + +export const triggerRouteChangeConfirmationEvent = (targetUrl: string): void => { + const ev = new CustomEvent('routeChangeConfirmationEvent', { detail: { targetUrl } }); + if (!isServer) window.dispatchEvent(ev); +}; + +const createForceClickEvent = (event: MouseEvent): ForceAnchorClickEvent => { + const res = new MouseEvent('click', event) as ForceAnchorClickEvent; + res.isForceAnchorClickEvent = true; + return res; +}; + +export const RouteChangesProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [freezeRequests, setFreezeRequests] = useState([]); + + useEffect(() => { + const abortController = new AbortController(); + + const handleAnchorClick = (event: MouseEvent | ForceAnchorClickEvent) => { + const target = event.currentTarget as HTMLAnchorElement; + const isFrozen = freezeRequests.length !== 0; + if (isFrozen && !(event as ForceAnchorClickEvent).isForceAnchorClickEvent) { + event.preventDefault(); + event.stopPropagation(); + window.addEventListener( + 'routeChangeConfirmationEvent', + (ev) => { + if (ev.detail.targetUrl === target.href) { + const forceClickEvent = createForceClickEvent(event); + target.dispatchEvent(forceClickEvent); + } + }, + { signal: abortController.signal }, + ); + + triggerBeforeRouteChangeEvent(target.href); + return; + } + + triggerRouteChangeStartEvent(target.href); + }; + + const handleAnchors = (anchors: NodeListOf) => { + anchors.forEach((a) => { + a.addEventListener('click', handleAnchorClick, { signal: abortController.signal, capture: true }); + }); + }; + + const handleMutation: MutationCallback = (mutationList) => { + mutationList.forEach((record) => { + if (record.type === 'childList' && record.target instanceof HTMLElement) { + const anchors: NodeListOf = record.target.querySelectorAll('a[href]'); + handleAnchors(anchors); + } + }); + }; + + const anchors: NodeListOf = document.querySelectorAll('a[href]'); + handleAnchors(anchors); + + const mutationObserver = new MutationObserver(handleMutation); + + mutationObserver.observe(document, { childList: true, subtree: true }); + + const pushStateProxy = new Proxy(window.history.pushState, { + apply: (target, thisArg, argArray: PushStateInput) => { + triggerRouteChangeEndEvent(argArray[2]); + return target.apply(thisArg, argArray); + }, + getPrototypeOf: (target) => { + return target; + }, + }); + + window.history.pushState = pushStateProxy; + + return () => { + mutationObserver.disconnect(); + abortController.abort(); + window.history.pushState = Object.getPrototypeOf(pushStateProxy); + }; + }, [freezeRequests]); + + return ( + + {children} + + ); +}; + +interface RouteChangeCallbacks { + onBeforeRouteChange?: (target: string) => boolean; // if `false` prevents a route change until `allowRouteChange` is called + onRouteChangeStart?: (target: string) => void; + onRouteChangeComplete?: (target: HistoryURL) => void; +} + +const useRouteChangeEvents = (callbacks: RouteChangeCallbacks) => { + const id = useRef(nanoid()); + const { request, revoke } = useFreezeRequestsContext(); + const [confrimationTarget, setConfirmationTarget] = useState(null); + + useEffect(() => { + request(id.current); + return () => revoke(id.current); + }, []); + + useEffect(() => { + const abortController = new AbortController(); + + window.addEventListener( + 'beforeRouteChangeEvent', + (ev) => { + const { targetUrl } = ev.detail; + const shouldProceed = callbacks.onBeforeRouteChange && callbacks.onBeforeRouteChange(targetUrl); + if (shouldProceed) { + triggerRouteChangeConfirmationEvent(targetUrl); + } else { + setConfirmationTarget(targetUrl); + } + }, + { signal: abortController.signal }, + ); + + window.addEventListener( + 'routeChangeEndEvent', + (ev) => { + callbacks.onRouteChangeComplete && callbacks.onRouteChangeComplete(ev.detail.targetUrl); + }, + { signal: abortController.signal }, + ); + + window.addEventListener( + 'routeChangeStartEvent', + (ev) => { + callbacks.onRouteChangeStart && callbacks.onRouteChangeStart(ev.detail.targetUrl); + }, + { signal: abortController.signal }, + ); + + return () => { + abortController.abort(); + }; + }, [callbacks]); + + return { + allowRouteChange: () => { + if (!confrimationTarget) { + console.warn('allowRouteChange called for no specified confirmation target'); + return; + } + triggerRouteChangeConfirmationEvent(confrimationTarget); + }, + }; +}; + +export default useRouteChangeEvents; diff --git a/src/app/write/hooks/index.ts b/src/app/write/hooks/index.ts new file mode 100644 index 00000000..78d98615 --- /dev/null +++ b/src/app/write/hooks/index.ts @@ -0,0 +1,4 @@ +export { default as useCustomRouter } from './useCustomRouter'; +export { default as useLeaveModal } from './useLeaveModal'; +export { default as useSetFormData } from './useSetFormData'; +export { default as useWriteForm } from './useWriteForm'; diff --git a/src/app/write/hooks/useCustomRouter.ts b/src/app/write/hooks/useCustomRouter.ts new file mode 100644 index 00000000..4720e836 --- /dev/null +++ b/src/app/write/hooks/useCustomRouter.ts @@ -0,0 +1,70 @@ +import { useEffect, useRef, useState } from 'react'; +import { useRouter as usePrimitiveRouter } from 'next/navigation'; +import { + triggerBeforeRouteChangeEvent, + triggerRouteChangeStartEvent, + useFreezeRequestsContext, +} from '@/app/write/contexts/RouteChangeProvider'; + +interface NavigateOptions { + scroll?: boolean; +} + +type AppRouterInstance = ReturnType; + +const createRouterProxy = (router: AppRouterInstance, isFrozen: boolean, signal?: AbortSignal) => + new Proxy(router, { + get: (target, prop, receiver) => { + if (prop === 'push') { + return (href: string, options?: NavigateOptions) => { + const resolvePush = () => { + triggerRouteChangeStartEvent(href); + Reflect.apply(target.push, this, [href, options]); + }; + + if (isFrozen) { + window.addEventListener( + 'routeChangeConfirmationEvent', + (ev) => { + if (ev.detail.targetUrl === href) resolvePush(); + }, + { signal }, + ); + + triggerBeforeRouteChangeEvent(href); + return; + } + resolvePush(); + }; + } + + return Reflect.get(target, prop, receiver); + }, + }); + +const useCustomRouter = (): AppRouterInstance => { + const router = usePrimitiveRouter(); + const { freezeRequests } = useFreezeRequestsContext(); + const abortControllerRef = useRef(new AbortController()); + const [routerProxy, setRouterProxy] = useState( + createRouterProxy(router, freezeRequests.length !== 0, abortControllerRef.current.signal), + ); + + useEffect(() => { + // eslint-disable-next-line react-hooks/exhaustive-deps + return () => abortControllerRef.current.abort(); + }, []); + + useEffect(() => { + abortControllerRef.current.abort(); + const abortController = new AbortController(); + + setRouterProxy(createRouterProxy(router, freezeRequests.length !== 0, abortController.signal)); + + return () => abortController.abort(); + }, [router, freezeRequests]); + + return routerProxy; +}; + +export default useCustomRouter; diff --git a/src/app/write/hooks/useLeaveModal.tsx b/src/app/write/hooks/useLeaveModal.tsx new file mode 100644 index 00000000..f50ba825 --- /dev/null +++ b/src/app/write/hooks/useLeaveModal.tsx @@ -0,0 +1,36 @@ +import { useOverlay } from '@/hooks'; +import useRouteChangeEvents from '../contexts/RouteChangeProvider'; +import { useCallback } from 'react'; +import { useRouter } from 'next/navigation'; +import { FreezeModal } from '@/app/write/components'; +import useFormStore from '../store/useFormStore'; + +const useLeaveModal = (shouldPreventRouteChange: boolean) => { + const [openModal, closeModal] = useOverlay(); + const { reset } = useFormStore(); + const router = useRouter(); + useRouteChangeEvents({ + onBeforeRouteChange: useCallback( + (targetUrl: string) => { + if (shouldPreventRouteChange) { + openModal( + { + if (reset) reset(); + router.push(targetUrl); + closeModal(); + }} + />, + ); + + return false; + } + return true; + }, + [closeModal, openModal, reset, router, shouldPreventRouteChange], + ), + }); +}; + +export default useLeaveModal; diff --git a/src/app/write/hooks/useWriteForm.ts b/src/app/write/hooks/useWriteForm.ts index 165772f6..eff5607a 100644 --- a/src/app/write/hooks/useWriteForm.ts +++ b/src/app/write/hooks/useWriteForm.ts @@ -3,7 +3,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import useFormStore from '@/app/write/store/useFormStore'; import { boardSchema, BoardSchema, StepOneSchema, stepOneSchema } from '@/app/write/lib/schema'; -export const useWriteForm = () => { +const useWriteForm = () => { const { stepOne, stepTwo } = useFormStore(); const stepOneMethod = useForm({ @@ -22,3 +22,5 @@ export const useWriteForm = () => { return { stepOneMethod, stepTwoMethod }; }; + +export default useWriteForm; diff --git a/src/app/write/page.tsx b/src/app/write/page.tsx index 8fe8b344..5143b3a2 100644 --- a/src/app/write/page.tsx +++ b/src/app/write/page.tsx @@ -1,13 +1,13 @@ 'use client'; -import FirstStep from './components/Form/FirstStep'; -import SecondStep from './components/Form/SecondStep'; -import { wrapper } from './style.css'; -import { useFunnel } from '@/hooks'; -import { useProfile } from '@/api/hooks'; -import WriteTitle from './components/WriteTitle/WriteTitle'; -import useFormStore from './store/useFormStore'; + import { useEffect } from 'react'; import { useRouter } from 'next/navigation'; +import { FirstStep, SecondStep, WriteTitle } from '@/app/write/components'; +import { useFunnel } from '@/hooks'; +import { useProfile } from '@/api/hooks'; +import useFormStore from '@/app/write/store/useFormStore'; +import { useLeaveModal } from '@/app/write/hooks'; +import { wrapper } from './style.css'; export default function Write() { const [step, { prevStep, nextStep }] = useFunnel(['1', '2']); @@ -32,6 +32,8 @@ export default function Write() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + useLeaveModal(true); + return (
diff --git a/src/components/Dropdown/index.ts b/src/components/Dropdown/index.ts index 86764cb5..7f3541b4 100644 --- a/src/components/Dropdown/index.ts +++ b/src/components/Dropdown/index.ts @@ -8,7 +8,7 @@ import DropdownMenu from './DropdownMenu'; import DropdownModal from './DropdownModal'; import DropdownBottomSheet from './DropdownBottomSheet'; -const DropdownRoot = Object.assign(Dropdown, { +const DropdownRoot = Object.assign(Dropdown || {}, { Button: DropdownButton, Toggle: DropdownToggle, Item: DropdownItem, diff --git a/src/components/Input/Input.css.ts b/src/components/Input/Input.css.ts index bf9b0428..54012eb3 100644 --- a/src/components/Input/Input.css.ts +++ b/src/components/Input/Input.css.ts @@ -125,6 +125,10 @@ export const bottomSheetContent = style({ padding: `0 ${space.sm}`, }); +globalStyle(`::-webkit-input-placeholder`, { + color: color.hint, +}); + globalStyle(`${clearButton} > img`, { margin: '0 auto', backgroundPosition: 'center', diff --git a/src/hooks/useFunnel/useFunnel.ts b/src/hooks/useFunnel/useFunnel.tsx similarity index 69% rename from src/hooks/useFunnel/useFunnel.ts rename to src/hooks/useFunnel/useFunnel.tsx index 34191c95..ba62e8f0 100644 --- a/src/hooks/useFunnel/useFunnel.ts +++ b/src/hooks/useFunnel/useFunnel.tsx @@ -1,5 +1,8 @@ import { useRouter } from 'next/navigation'; import { useState } from 'react'; +import { useOverlay } from '@/hooks'; +import useFormStore from '@/app/write/store/useFormStore'; +import { FreezeModal } from '@/app/write/components'; function assertString(value: unknown): asserts value is string { if (typeof value !== 'string') { @@ -18,11 +21,27 @@ const useFunnel = ( ] => { assertString(steps[0]); const [step, setStep] = useState(steps[0]); + const [openModal, closeModal] = useOverlay(); + const { reset } = useFormStore(); const router = useRouter(); + + const showModal = () => { + openModal( + { + closeModal(); + reset(); + router.back(); + }} + onClose={closeModal} + />, + ); + }; + const prevStep = () => { const currentStepIndex = steps.indexOf(step); if (currentStepIndex === 0) { - router.back(); + showModal(); return; } const newStep = steps[currentStepIndex - 1]; diff --git a/src/tests/setupTests.ts b/src/tests/setupTests.ts index 73e9ab55..0fc1d0e6 100644 --- a/src/tests/setupTests.ts +++ b/src/tests/setupTests.ts @@ -3,18 +3,19 @@ import { expect } from 'vitest'; import { cleanup } from '@testing-library/react'; import '@testing-library/jest-dom'; import matchers from '@testing-library/jest-dom/matchers'; +import 'vitest-canvas-mock'; expect.extend(matchers); beforeAll(() => { - server.listen({ onUnhandledRequest: 'error' }); + server.listen({ onUnhandledRequest: 'error' }); }); afterEach(() => { - server.resetHandlers(); - cleanup(); + server.resetHandlers(); + cleanup(); }); afterAll(() => { - server.close(); + server.close(); }); diff --git a/vitest.config.ts b/vitest.config.ts index c2bf8d13..f3e3b3ec 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -11,9 +11,18 @@ export default defineConfig({ setupFiles: ['./src/tests/setupTests.ts'], maxThreads: 15, minThreads: 8, + deps: { + inline: ['vitest-canvas-mock'], + }, + threads: false, + environmentOptions: { + jsdom: { + resources: 'usable', + }, + }, }, plugins: [react(), vanillaExtractPlugin()], resolve: { alias: [{ find: '@', replacement: path.resolve(__dirname, 'src') }], }, -}); +}); \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 4aad430c..68a814db 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3812,7 +3812,7 @@ __metadata: languageName: node linkType: hard -"color-name@npm:~1.1.4": +"color-name@npm:^1.1.4, color-name@npm:~1.1.4": version: 1.1.4 resolution: "color-name@npm:1.1.4" checksum: b0445859521eb4021cd0fb0cc1a75cecf67fceecae89b63f62b201cca8d345baf8b952c966862a9d9a2632987d4f6581f0ec8d957dfacece86f0a7919316f610 @@ -4038,6 +4038,13 @@ __metadata: languageName: node linkType: hard +"cssfontparser@npm:^1.2.1": + version: 1.2.1 + resolution: "cssfontparser@npm:1.2.1" + checksum: 952d487cddab591fb944f2a4c326a7736bc963784a6d92b6ad4051f3bf5ee49a732eff62e29a52ff085197cb07f5bd66525a2245ded7fd356113ac81be9238b9 + languageName: node + linkType: hard + "cssstyle@npm:^3.0.0": version: 3.0.0 resolution: "cssstyle@npm:3.0.0" @@ -6601,6 +6608,16 @@ __metadata: languageName: node linkType: hard +"jest-canvas-mock@npm:~2.4.0": + version: 2.4.0 + resolution: "jest-canvas-mock@npm:2.4.0" + dependencies: + cssfontparser: ^1.2.1 + moo-color: ^1.0.2 + checksum: feda3c9a3301dc8f1ce3eee3d6cd944d3e2f50d713f1a3f159bdd85b88b275871c400dc0a4a50d14b36eef347dde2d7223e50d1109501b80668d87253712675e + languageName: node + linkType: hard + "jest-diff@npm:^29.0.3, jest-diff@npm:^29.6.1": version: 29.6.1 resolution: "jest-diff@npm:29.6.1" @@ -7478,6 +7495,15 @@ __metadata: languageName: node linkType: hard +"moo-color@npm:^1.0.2": + version: 1.0.3 + resolution: "moo-color@npm:1.0.3" + dependencies: + color-name: ^1.1.4 + checksum: 02bf59b6bbd5e86641bc062e2dc0843e6e579e18ef67e1c8e93bfc01945df578f20e66ce16aa9632db2aa0e16806e0914a26eb345a804f45fff1ae12a8906a29 + languageName: node + linkType: hard + "morgan@npm:^1.10.0": version: 1.10.0 resolution: "morgan@npm:1.10.0" @@ -7562,6 +7588,15 @@ __metadata: languageName: node linkType: hard +"nanoid@npm:^4.0.2": + version: 4.0.2 + resolution: "nanoid@npm:4.0.2" + bin: + nanoid: bin/nanoid.js + checksum: 747c399cea4664dd0be1d0ec498ffd1ef8f1f5221676fc8b577e3f46f66d9afcddb9595d63d19a2e78d0bc6cc33984f65e66bf1682c850b9e26288883d96b53f + languageName: node + linkType: hard + "natural-compare-lite@npm:^1.4.0": version: 1.4.0 resolution: "natural-compare-lite@npm:1.4.0" @@ -10132,6 +10167,17 @@ __metadata: languageName: node linkType: hard +"vitest-canvas-mock@npm:^0.3.2": + version: 0.3.2 + resolution: "vitest-canvas-mock@npm:0.3.2" + dependencies: + jest-canvas-mock: ~2.4.0 + peerDependencies: + vitest: "*" + checksum: ed564f94f4dd44ffb2a1e25273e6ca815e4ff273c460fd4caa482243f6a154ac64e1216f16b477b7287204517731ca337a3104119fe0247efbeeda23d28d4160 + languageName: node + linkType: hard + "vitest@npm:^0.31.1": version: 0.31.4 resolution: "vitest@npm:0.31.4" @@ -10529,6 +10575,7 @@ __metadata: lodash: ^4.17.21 lottie-web: ^5.12.2 msw: ^1.2.1 + nanoid: ^4.0.2 next: 13.4.12 postcss: ^8.4.23 prettier: ^2.8.8 @@ -10543,6 +10590,7 @@ __metadata: typescript: ^5.1.3 vite: ^4.3.9 vitest: ^0.31.1 + vitest-canvas-mock: ^0.3.2 zod: ^3.21.4 zustand: ^4.3.8 languageName: unknown