diff --git a/src/__tests__/hooks/useBoardName.spec.ts b/src/__tests__/hooks/useBoardName.spec.ts new file mode 100644 index 0000000..9839f72 --- /dev/null +++ b/src/__tests__/hooks/useBoardName.spec.ts @@ -0,0 +1,47 @@ +import { act, renderHook } from '@testing-library/react' +import { useBoardName } from '@/hooks/useBoardName' + +describe('useBoardName()', () => { + it('description', () => { + // Given + const { result } = renderHook(() => useBoardName()) + + // When + act(() => { + result.current.setBoardName('board name') + }) + + // Then + expect(result.current.description).toEqual('10/15자') + }) + + describe('errorMessage', () => { + it('should return error message on empty string', () => { + // Given + const { result } = renderHook(() => useBoardName()) + + // When + act(() => { + result.current.setBoardName('') + }) + + // Then + expect(result.current.errorMessage).toEqual( + '최소 한글자 이상 입력해주세요', + ) + }) + + it('should return error message on value longer than max length', () => { + // Given + const { result } = renderHook(() => useBoardName()) + + // When + act(() => { + result.current.setBoardName('abcdefghijklmnop') + }) + + // Then + expect(result.current.errorMessage).toEqual('15자 이내로 입력 가능해요') + }) + }) +}) diff --git a/src/__tests__/hooks/useInputValidation.spec.ts b/src/__tests__/hooks/useInputValidation.spec.ts new file mode 100644 index 0000000..6bbcf28 --- /dev/null +++ b/src/__tests__/hooks/useInputValidation.spec.ts @@ -0,0 +1,182 @@ +import { act, renderHook } from '@testing-library/react' +import { useInputValidation, Validation } from '@/hooks/useInputValidation' + +describe('useInputValidation()', () => { + describe('value', () => { + it('should return initialValue as value', () => { + // Given + const initialValue = 'initialValue' + + // When + const { result } = renderHook(() => useInputValidation(initialValue)) + + // Then + expect(result.current.value).toEqual(initialValue) + }) + + it('should set value to the new value when setValue is called', () => { + // Given + const initialValue = 'initialValue' + const { result } = renderHook(() => useInputValidation(initialValue)) + + // When + const newValue = 'newValue' + act(() => { + result.current.setValue(newValue) + }) + + // Then + expect(result.current.value).toEqual(newValue) + }) + }) + describe('isDirty', () => { + it('should set isDirty to false before the value is changed', () => { + // Given + const initialValue = 'initialValue' + + // When + const { result } = renderHook(() => useInputValidation(initialValue)) + + // Then + expect(result.current.isDirty).toBeFalsy() + }) + + it('should set isDirty to true when the value is changed', () => { + // Given + const initialValue = 'initialValue' + const { result } = renderHook(() => useInputValidation(initialValue)) + + // When + const newValue = 'newValue' + act(() => { + result.current.setValue(newValue) + }) + + // Then + expect(result.current.isDirty).toBeTruthy() + }) + + it('should set isDirty to false when the value is not changed', () => { + // Given + const initialValue = 'initialValue' + const { result } = renderHook(() => useInputValidation(initialValue)) + + // When + const newValue = 'initialValue' + act(() => { + result.current.setValue(newValue) + }) + + // Then + expect(result.current.isDirty).toBeFalsy() + }) + }) + + describe('isInvalid', () => { + it('should set isInvalid to true when a validation fails', () => { + // Given + const initialValue = 'initialValue' + const validations: Validation[] = [ + { + validator: () => false, + errorMessage: 'error message1', + }, + { + validator: () => true, + errorMessage: 'error message2', + }, + ] + + // When + const { result } = renderHook(() => + useInputValidation(initialValue, validations), + ) + + // Then + expect(result.current.isInvalid).toBeTruthy() + }) + + it('should set isInvalid to false when all validations pass', () => { + // Given + const initialValue = 'initialValue' + const validations: Validation[] = [ + { + validator: () => true, + errorMessage: 'error message1', + }, + { + validator: () => true, + errorMessage: 'error message2', + }, + { + validator: () => true, + errorMessage: 'error message3', + }, + ] + + // When + const { result } = renderHook(() => + useInputValidation(initialValue, validations), + ) + + // Then + expect(result.current.isInvalid).toBeFalsy() + }) + }) + + describe('errorMessage', () => { + it('should set errorMessage to the first validation that fails ', () => { + // Given + const initialValue = 'initialValue' + const validations: Validation[] = [ + { + validator: () => false, + errorMessage: 'error message1', + }, + { + validator: () => false, + errorMessage: 'error message2', + }, + { + validator: () => false, + errorMessage: 'error message3', + }, + ] + + // When + const { result } = renderHook(() => + useInputValidation(initialValue, validations), + ) + + // Then + expect(result.current.errorMessage).toEqual('error message1') + }) + + it('should set errorMessage to empty string when all validations pass', () => { + // Given + const initialValue = 'initialValue' + const validations: Validation[] = [ + { + validator: () => true, + errorMessage: 'error message1', + }, + { + validator: () => true, + errorMessage: 'error message2', + }, + { + validator: () => true, + errorMessage: 'error message3', + }, + ] + + // When + const { result } = renderHook(() => + useInputValidation(initialValue, validations), + ) + + // Then + expect(result.current.errorMessage).toEqual('') + }) + }) +}) diff --git a/src/__tests__/lib/utils/clipboard.spec.ts b/src/__tests__/lib/utils/clipboard.spec.ts new file mode 100644 index 0000000..cbe3abb --- /dev/null +++ b/src/__tests__/lib/utils/clipboard.spec.ts @@ -0,0 +1,39 @@ +import { copyToClipboard } from '@/lib/utils' + +describe('lib/utils/clipboard', () => { + describe('copyToClipboard()', () => { + beforeEach(() => { + Object.assign(navigator, { + clipboard: { + writeText: jest.fn().mockImplementation(() => Promise.resolve()), + }, + }) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('Should Copy target string to clipboard', async () => { + // Given + const target = 'copy target string' + + // When + await copyToClipboard(target) + + // Then + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(target) + }) + + it('Should return resolved promise object when it is done', () => { + // Given + const target = 'copy target string' + + // When + const result = copyToClipboard(target) + + // Then + expect(result).toEqual(Promise.resolve()) + }) + }) +}) diff --git a/src/app/(home)/_components/CopyLinkBtn.tsx b/src/app/(home)/_components/CopyLinkBtn.tsx index 2a72ac4..12d7a71 100644 --- a/src/app/(home)/_components/CopyLinkBtn.tsx +++ b/src/app/(home)/_components/CopyLinkBtn.tsx @@ -1,20 +1,19 @@ 'use client' import LinkIcon from 'public/icons/linkcopy.svg' -import TwoPolaroidsIcon from 'public/icons/twopolaroids.svg' -import Modal from '@/components/Modal' import { useState } from 'react' +import LinkCopiedModal from '@/app/(home)/_components/LinkCopiedModal' +import { copyToClipboard } from '@/lib/utils' const CopyLinkBtn = () => { - const [showLinkCopyModal, setShowLinkCopyModal] = useState(false) + const [isLinkCopiedModalOpen, setIsLinkCopiedModalOpen] = useState(false) - const closeModal = () => setShowLinkCopyModal(false) + const openLinkCopiedModal = () => setIsLinkCopiedModalOpen(true) + const closeLinkCopiedModal = () => setIsLinkCopiedModalOpen(false) - const copyLink = () => { + const copyCurrentUrl = () => { const currentURL = window.location.href - return navigator.clipboard.writeText(currentURL).then(() => { - setShowLinkCopyModal(true) - }) + return copyToClipboard(currentURL).then(openLinkCopiedModal) } return ( @@ -26,17 +25,14 @@ const CopyLinkBtn = () => { type="button" className="rounded-[30px] bg-gray-100 p-3 shadow-[0_4px_8px_0_rgba(0,0,0,0.15)]" aria-label="copy link" - onClick={copyLink} + onClick={copyCurrentUrl} > - - }> - 링크가 복사되었습니다! - {'POLABO를\n 지인들에게도 알려주세요!'} - - - + ) } diff --git a/src/app/(home)/_components/CreateBoardBtn.tsx b/src/app/(home)/_components/CreateBoardBtn.tsx index d40fe03..50baa20 100644 --- a/src/app/(home)/_components/CreateBoardBtn.tsx +++ b/src/app/(home)/_components/CreateBoardBtn.tsx @@ -8,15 +8,15 @@ import GoToLoginModal from './GoToLoginModal' const CreateBoardBtn = () => { const router = useRouter() - const [loginModalOpen, setLoginModalOpen] = useState(false) + const [isLoginModalOpen, setIsLoginModalOpen] = useState(false) const { status } = useSession() - const handleClick = () => { + const handleCreateButtonClick = () => { if (status === 'authenticated') { router.push('/board/create') } else { - setLoginModalOpen(true) + setIsLoginModalOpen(true) } } @@ -25,13 +25,13 @@ const CreateBoardBtn = () => { setLoginModalOpen(false)} + isOpen={isLoginModalOpen} + onClose={() => setIsLoginModalOpen(false)} /> ) diff --git a/src/app/(home)/_components/LinkCopiedModal.tsx b/src/app/(home)/_components/LinkCopiedModal.tsx new file mode 100644 index 0000000..1483958 --- /dev/null +++ b/src/app/(home)/_components/LinkCopiedModal.tsx @@ -0,0 +1,25 @@ +import React from 'react' +import Modal from '@/components/Modal' +import TwoPolaroidsIcon from 'public/icons/twopolaroids.svg' + +interface LinkCopiedModalProps { + isOpen: boolean + onClose: () => void +} + +const LinkCopiedModal = ({ + isOpen, + onClose: handleCloseModal, +}: LinkCopiedModalProps) => { + return ( + + }> + 링크가 복사되었습니다! + {'POLABO를\n 지인들에게도 알려주세요!'} + + + + ) +} + +export default LinkCopiedModal diff --git a/src/app/board/create/_components/BoardAvailabilityCheckModal.tsx b/src/app/board/create/_components/BoardAvailabilityCheckModal.tsx index cfc5864..d9a19ec 100644 --- a/src/app/board/create/_components/BoardAvailabilityCheckModal.tsx +++ b/src/app/board/create/_components/BoardAvailabilityCheckModal.tsx @@ -1,20 +1,13 @@ 'use client' -import { useEffect, useState } from 'react' -import { getBoardAvailableCount } from '@/lib' import Modal from '@/components/Modal' import TwoPolaroidsIcon from 'public/icons/twopolaroids.svg' import { useRouter } from 'next/navigation' +import { useBoardAvailability } from '@/hooks' const BoardAvailabilityCheckModal = () => { - const [showModal, setShowModal] = useState(false) const router = useRouter() - - useEffect(() => { - getBoardAvailableCount().then((availableBoardCount) => { - setShowModal(availableBoardCount === 0) - }) - }, []) + const isBoardAvailable = useBoardAvailability() const redirectToHome = () => { router.replace('/') @@ -23,8 +16,8 @@ const BoardAvailabilityCheckModal = () => { return ( setShowModal(false)} + isOpen={!isBoardAvailable} + onClose={() => {}} > }> diff --git a/src/app/board/create/_components/BoardNameForm.tsx b/src/app/board/create/_components/BoardNameForm.tsx index 3794f8c..866c243 100644 --- a/src/app/board/create/_components/BoardNameForm.tsx +++ b/src/app/board/create/_components/BoardNameForm.tsx @@ -2,9 +2,8 @@ import Button from '@/components/Button' import TextInput from '@/components/TextInput' -import { useState, ReactNode } from 'react' - -const MAX_BOARD_NAME_LENGTH = 15 +import { ReactNode } from 'react' +import { useBoardName } from '@/hooks/useBoardName' interface BoardNameFormProps { children: ReactNode @@ -12,18 +11,14 @@ interface BoardNameFormProps { } const BoardNameForm = ({ children, createBoard }: BoardNameFormProps) => { - const [title, setTitle] = useState('') - const [hasError, setHasError] = useState(false) - const isEmpty = title.length === 0 - - const onInput = (value: string) => { - setTitle(value) - if (value.length > MAX_BOARD_NAME_LENGTH) { - setHasError(true) - } else { - setHasError(false) - } - } + const { + boardName, + setBoardName, + isDirty, + isInvalid, + errorMessage, + description, + } = useBoardName() return ( <> @@ -32,11 +27,11 @@ const BoardNameForm = ({ children, createBoard }: BoardNameFormProps) => { 보드 주제를 정해주세요! {children} @@ -44,8 +39,8 @@ const BoardNameForm = ({ children, createBoard }: BoardNameFormProps) => { type="submit" size="lg" className="mb-12" - disabled={hasError || isEmpty} - onClick={() => createBoard(title)} + disabled={isInvalid} + onClick={() => createBoard(boardName)} > 완료 diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 0000000..c14f920 --- /dev/null +++ b/src/hooks/index.ts @@ -0,0 +1,3 @@ +export * from './useBoardAvailability' +export * from './useBoardName' +export * from './useInputValidation' diff --git a/src/hooks/useBoardAvailability.ts b/src/hooks/useBoardAvailability.ts new file mode 100644 index 0000000..f5ce2cc --- /dev/null +++ b/src/hooks/useBoardAvailability.ts @@ -0,0 +1,17 @@ +import { useEffect, useState } from 'react' +import { getBoardAvailableCount } from '@/lib' + +export const useBoardAvailability = () => { + const [isBoardAvailable, setIsBoardAvailable] = useState(true) + + useEffect(() => { + const checkBoardAvailability = async () => { + const availableBoardCount = await getBoardAvailableCount() + setIsBoardAvailable(availableBoardCount > 0) + } + + checkBoardAvailability() + }, []) + + return isBoardAvailable +} diff --git a/src/hooks/useBoardName.ts b/src/hooks/useBoardName.ts new file mode 100644 index 0000000..5daa445 --- /dev/null +++ b/src/hooks/useBoardName.ts @@ -0,0 +1,34 @@ +import { useMemo } from 'react' +import { useInputValidation, Validation } from '@/hooks/useInputValidation' + +const MAX_BOARD_NAME_LENGTH = 15 + +const validations: Validation[] = [ + { + validator: (value: string) => value.length <= MAX_BOARD_NAME_LENGTH, + errorMessage: `${MAX_BOARD_NAME_LENGTH}자 이내로 입력 가능해요`, + }, + { + validator: (value: string) => value.length > 0, + errorMessage: `최소 한글자 이상 입력해주세요`, + }, +] + +export const useBoardName = () => { + const { value, setValue, errorMessage, isDirty, isInvalid } = + useInputValidation('', validations) + + const description = useMemo( + () => `${value.length}/${MAX_BOARD_NAME_LENGTH}자`, + [value], + ) + + return { + boardName: value, + setBoardName: setValue, + isDirty, + isInvalid, + errorMessage, + description, + } +} diff --git a/src/hooks/useInputValidation.ts b/src/hooks/useInputValidation.ts new file mode 100644 index 0000000..f1db184 --- /dev/null +++ b/src/hooks/useInputValidation.ts @@ -0,0 +1,45 @@ +import { useCallback, useEffect, useState } from 'react' + +export type Validation = { + validator: (value: T) => boolean + errorMessage: string +} + +export const useInputValidation = ( + initialValue: T, + validations: Validation[] = [], +) => { + const [value, setValue] = useState(initialValue) + const [isDirty, setIsDirty] = useState(false) + const [errorMessage, setErrorMessage] = useState('') + + useEffect(() => { + const failedValidation = validations.find( + (validation) => !validation.validator(value), + ) + + if (failedValidation) { + setErrorMessage(failedValidation.errorMessage) + } else { + setErrorMessage('') + } + }, [value, validations]) + + const wrappedSetValue = useCallback( + (newValue: T) => { + if (!isDirty && value !== newValue) { + setIsDirty(true) + } + setValue(newValue) + }, + [isDirty, value], + ) + + return { + value, + setValue: wrappedSetValue, + errorMessage, + isDirty, + isInvalid: !!errorMessage, + } +} diff --git a/src/lib/utils/clipboard.ts b/src/lib/utils/clipboard.ts new file mode 100644 index 0000000..658ef97 --- /dev/null +++ b/src/lib/utils/clipboard.ts @@ -0,0 +1,3 @@ +export const copyToClipboard = (target: string): Promise => { + return navigator.clipboard.writeText(target) +} diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts new file mode 100644 index 0000000..57c4f83 --- /dev/null +++ b/src/lib/utils/index.ts @@ -0,0 +1 @@ +export * from './clipboard' diff --git a/tsconfig.json b/tsconfig.json index 6c52f5f..78b47a7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,7 +21,7 @@ "@/*": ["./src/*"], "public/*": ["./public/*"] }, - "types": ["node"] + "types": ["node", "jest"] }, "include": [ "next-env.d.ts",