From b22bca3a995b95f1f14b7caa0ed67e7ee59401e3 Mon Sep 17 00:00:00 2001 From: Jiho Park <59248080+jihorobert@users.noreply.github.com> Date: Wed, 2 Oct 2024 10:46:47 +0900 Subject: [PATCH] refactor(fe): refactor settings page (#2128) * fix(fe): refactor save * fix(fe): refactor settings * chore(fe): change import path name * fix(fe): change navigation * chore(fe): fix typo * chore(fe): delete import react * chore(fe): delete import react * chore(fe): use cn --- .../_components/ConfirmNavigation.tsx | 55 +++ .../settings/_components/CurrentPwSection.tsx | 93 ++++ .../(main)/settings/_components/IdSection.tsx | 20 + .../settings/_components/LogoSection.tsx | 22 + .../settings/_components/MajorSection.tsx | 107 ++++ .../settings/_components/NameSection.tsx | 46 ++ .../settings/_components/NewPwSection.tsx | 79 +++ .../_components/ReEnterNewPwSection.tsx | 69 +++ .../settings/_components/SaveButton.tsx | 28 ++ .../settings/_components/StudentIdSection.tsx | 54 ++ .../settings/_components/TopicSection.tsx | 12 + apps/frontend/app/(main)/settings/page.tsx | 461 ++++-------------- apps/frontend/types/type.ts | 8 + 13 files changed, 686 insertions(+), 368 deletions(-) create mode 100644 apps/frontend/app/(main)/settings/_components/ConfirmNavigation.tsx create mode 100644 apps/frontend/app/(main)/settings/_components/CurrentPwSection.tsx create mode 100644 apps/frontend/app/(main)/settings/_components/IdSection.tsx create mode 100644 apps/frontend/app/(main)/settings/_components/LogoSection.tsx create mode 100644 apps/frontend/app/(main)/settings/_components/MajorSection.tsx create mode 100644 apps/frontend/app/(main)/settings/_components/NameSection.tsx create mode 100644 apps/frontend/app/(main)/settings/_components/NewPwSection.tsx create mode 100644 apps/frontend/app/(main)/settings/_components/ReEnterNewPwSection.tsx create mode 100644 apps/frontend/app/(main)/settings/_components/SaveButton.tsx create mode 100644 apps/frontend/app/(main)/settings/_components/StudentIdSection.tsx create mode 100644 apps/frontend/app/(main)/settings/_components/TopicSection.tsx diff --git a/apps/frontend/app/(main)/settings/_components/ConfirmNavigation.tsx b/apps/frontend/app/(main)/settings/_components/ConfirmNavigation.tsx new file mode 100644 index 0000000000..5e7afa67c6 --- /dev/null +++ b/apps/frontend/app/(main)/settings/_components/ConfirmNavigation.tsx @@ -0,0 +1,55 @@ +import type { Route } from 'next' +import type { NavigateOptions } from 'next/dist/shared/lib/app-router-context.shared-runtime' +import { useRouter } from 'next/navigation' +import type { MutableRefObject } from 'react' +import { useEffect } from 'react' +import { toast } from 'sonner' + +// const beforeUnloadHandler = (event: BeforeUnloadEvent) => { +// // Recommended +// event.preventDefault() + +// // Included for legacy support, e.g. Chrome/Edge < 119 +// event.returnValue = true +// return true +// } + +/** + * Prompt the user with a confirmation dialog when they try to navigate away from the page. + */ +export const useConfirmNavigation = ( + bypassConfirmation: MutableRefObject, + updateNow: boolean +) => { + const router = useRouter() + useEffect(() => { + const originalPush = router.push + const newPush = ( + href: string, + options?: NavigateOptions | undefined + ): void => { + if (updateNow) { + if (!bypassConfirmation.current) { + toast.error('You must update your information') + } else { + originalPush(href as Route, options) + } + return + } + if (!bypassConfirmation.current) { + const isConfirmed = window.confirm( + 'Are you sure you want to leave?\nYour changes have not been saved.\nIf you leave this page, all changes will be lost.\nDo you still want to proceed?' + ) + if (isConfirmed) { + originalPush(href as Route, options) + } + return + } + originalPush(href as Route, options) + } + router.push = newPush + return () => { + router.push = originalPush + } + }, [router, bypassConfirmation.current]) +} diff --git a/apps/frontend/app/(main)/settings/_components/CurrentPwSection.tsx b/apps/frontend/app/(main)/settings/_components/CurrentPwSection.tsx new file mode 100644 index 0000000000..0f5b7496a9 --- /dev/null +++ b/apps/frontend/app/(main)/settings/_components/CurrentPwSection.tsx @@ -0,0 +1,93 @@ +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { cn } from '@/lib/utils' +import invisible from '@/public/24_invisible.svg' +import visible from '@/public/24_visible.svg' +import type { SettingsFormat } from '@/types/type' +import Image from 'next/image' +import React from 'react' +import type { FieldErrors, UseFormRegister } from 'react-hook-form' +import { FaCheck } from 'react-icons/fa6' + +interface CurrentPwSectionProps { + currentPassword: string + isCheckButtonClicked: boolean + isPasswordCorrect: boolean + setPasswordShow: React.Dispatch> + passwordShow: boolean + checkPassword: () => Promise + register: UseFormRegister + errors: FieldErrors + updateNow: boolean +} + +export default function CurrentPwSection({ + currentPassword, + isCheckButtonClicked, + isPasswordCorrect, + setPasswordShow, + passwordShow, + checkPassword, + register, + errors, + updateNow +}: CurrentPwSectionProps) { + return ( + <> + +
+
+ + setPasswordShow(!passwordShow)} + > + {passwordShow + +
+ +
+ {errors.currentPassword && + errors.currentPassword.message === 'Required' && ( +
+ Required +
+ )} + {!errors.currentPassword && + isCheckButtonClicked && + (isPasswordCorrect ? ( +
+ Correct +
+ ) : ( +
+ Incorrect +
+ ))} + + ) +} diff --git a/apps/frontend/app/(main)/settings/_components/IdSection.tsx b/apps/frontend/app/(main)/settings/_components/IdSection.tsx new file mode 100644 index 0000000000..15fadd1b2d --- /dev/null +++ b/apps/frontend/app/(main)/settings/_components/IdSection.tsx @@ -0,0 +1,20 @@ +import { Input } from '@/components/ui/input' + +export default function IdSection({ + isLoading, + defaultUsername +}: { + isLoading: boolean + defaultUsername: string +}) { + return ( + <> + + + + ) +} diff --git a/apps/frontend/app/(main)/settings/_components/LogoSection.tsx b/apps/frontend/app/(main)/settings/_components/LogoSection.tsx new file mode 100644 index 0000000000..204dae8b4f --- /dev/null +++ b/apps/frontend/app/(main)/settings/_components/LogoSection.tsx @@ -0,0 +1,22 @@ +import codedangSymbol from '@/public/codedang-editor.svg' +import Image from 'next/image' + +export default function LogoSection() { + return ( +
+
+ codedang +

CODEDANG

+
+

Online Judge Platform for SKKU

+
+ ) +} diff --git a/apps/frontend/app/(main)/settings/_components/MajorSection.tsx b/apps/frontend/app/(main)/settings/_components/MajorSection.tsx new file mode 100644 index 0000000000..c48015077a --- /dev/null +++ b/apps/frontend/app/(main)/settings/_components/MajorSection.tsx @@ -0,0 +1,107 @@ +import { Button } from '@/components/ui/button' +import { + Command, + CommandInput, + CommandGroup, + CommandItem, + CommandList, + CommandEmpty +} from '@/components/ui/command' +import { + Popover, + PopoverTrigger, + PopoverContent +} from '@/components/ui/popover' +import { ScrollArea } from '@/components/ui/scroll-area' +import { majors } from '@/lib/constants' +import { cn } from '@/lib/utils' +import React from 'react' +import { FaChevronDown, FaCheck } from 'react-icons/fa6' + +interface MajorSectionProps { + majorOpen: boolean + setMajorOpen: React.Dispatch> + majorValue: string + setMajorValue: React.Dispatch> + updateNow: boolean + isLoading: boolean + defaultProfileValues: { + major?: string + } +} + +export default function MajorSection({ + majorOpen, + setMajorOpen, + majorValue, + setMajorValue, + updateNow, + isLoading, + defaultProfileValues +}: MajorSectionProps) { + return ( + <> + +
+ + + + + + + + + No major found. + + + {majors?.map((major) => ( + { + setMajorValue(currentValue) + setMajorOpen(false) + }} + > + + {major} + + ))} + + + + + + +
+ + ) +} diff --git a/apps/frontend/app/(main)/settings/_components/NameSection.tsx b/apps/frontend/app/(main)/settings/_components/NameSection.tsx new file mode 100644 index 0000000000..703606bf3f --- /dev/null +++ b/apps/frontend/app/(main)/settings/_components/NameSection.tsx @@ -0,0 +1,46 @@ +import { Input } from '@/components/ui/input' +import { cn } from '@/lib/utils' +import type { SettingsFormat } from '@/types/type' +import type { FieldErrors, UseFormRegister } from 'react-hook-form' + +interface NameSectionProps { + isLoading: boolean + updateNow: boolean + defaultProfileValues: { userProfile?: { realName?: string } } + register: UseFormRegister + errors: FieldErrors + realName: string +} + +export default function NameSection({ + isLoading, + updateNow, + defaultProfileValues, + register, + errors, + realName +}: NameSectionProps) { + return ( + <> + + + {realName && errors.realName && ( +
+ {errors.realName.message} +
+ )} + + ) +} diff --git a/apps/frontend/app/(main)/settings/_components/NewPwSection.tsx b/apps/frontend/app/(main)/settings/_components/NewPwSection.tsx new file mode 100644 index 0000000000..7d50a986eb --- /dev/null +++ b/apps/frontend/app/(main)/settings/_components/NewPwSection.tsx @@ -0,0 +1,79 @@ +import { Input } from '@/components/ui/input' +import { cn } from '@/lib/utils' +import invisible from '@/public/24_invisible.svg' +import visible from '@/public/24_visible.svg' +import type { SettingsFormat } from '@/types/type' +import Image from 'next/image' +import React from 'react' +import type { FieldErrors, UseFormRegister } from 'react-hook-form' + +interface NewPwSectionProps { + newPasswordShow: boolean + setNewPasswordShow: React.Dispatch> + newPasswordAble: boolean + isPasswordsMatch: boolean + newPassword: string + confirmPassword: string + updateNow: boolean + register: UseFormRegister + errors: FieldErrors +} + +export default function NewPwSection({ + newPasswordShow, + setNewPasswordShow, + newPasswordAble, + isPasswordsMatch, + newPassword, + confirmPassword, + updateNow, + register, + errors +}: NewPwSectionProps) { + return ( + <> +
+
+ + setNewPasswordShow(!newPasswordShow)} + > + {newPasswordShow + +
+
+ {errors.newPassword && newPasswordAble && ( +
+
    +
  • 8-20 characters
  • +
  • + Include two of the following: capital letters, small letters, + numbers +
  • +
+
+ )} + + ) +} diff --git a/apps/frontend/app/(main)/settings/_components/ReEnterNewPwSection.tsx b/apps/frontend/app/(main)/settings/_components/ReEnterNewPwSection.tsx new file mode 100644 index 0000000000..8a5a74ffd3 --- /dev/null +++ b/apps/frontend/app/(main)/settings/_components/ReEnterNewPwSection.tsx @@ -0,0 +1,69 @@ +import { Input } from '@/components/ui/input' +import invisible from '@/public/24_invisible.svg' +import visible from '@/public/24_visible.svg' +import type { SettingsFormat } from '@/types/type' +import Image from 'next/image' +import React from 'react' +import type { UseFormRegister, UseFormGetValues } from 'react-hook-form' + +interface ReEnterNewPwSectionProps { + confirmPasswordShow: boolean + setConfirmPasswordShow: React.Dispatch> + newPasswordAble: boolean + updateNow: boolean + register: UseFormRegister + getValues: UseFormGetValues + confirmPassword: string + isPasswordsMatch: boolean +} + +export default function ReEnterNewPwSection({ + confirmPasswordShow, + setConfirmPasswordShow, + newPasswordAble, + updateNow, + register, + getValues, + confirmPassword, + isPasswordsMatch +}: ReEnterNewPwSectionProps) { + return ( + <> + {/* Re-enter new password */} +
+
+ + setConfirmPasswordShow(!confirmPasswordShow)} + > + {confirmPasswordShow + +
+
+ {getValues('confirmPassword') && + (isPasswordsMatch ? ( +
+ Correct +
+ ) : ( +
+ Incorrect +
+ ))} + + ) +} diff --git a/apps/frontend/app/(main)/settings/_components/SaveButton.tsx b/apps/frontend/app/(main)/settings/_components/SaveButton.tsx new file mode 100644 index 0000000000..a444c56f2b --- /dev/null +++ b/apps/frontend/app/(main)/settings/_components/SaveButton.tsx @@ -0,0 +1,28 @@ +import { Button } from '@/components/ui/button' + +interface SaveButtonProps { + updateNow: boolean + saveAbleUpdateNow: boolean + saveAble: boolean + onSubmitClick: () => void +} + +export default function SaveButton({ + updateNow, + saveAbleUpdateNow, + saveAble, + onSubmitClick +}: SaveButtonProps) { + return ( +
+ +
+ ) +} diff --git a/apps/frontend/app/(main)/settings/_components/StudentIdSection.tsx b/apps/frontend/app/(main)/settings/_components/StudentIdSection.tsx new file mode 100644 index 0000000000..f2df8ca736 --- /dev/null +++ b/apps/frontend/app/(main)/settings/_components/StudentIdSection.tsx @@ -0,0 +1,54 @@ +import { Input } from '@/components/ui/input' +import { cn } from '@/lib/utils' +import type { SettingsFormat } from '@/types/type' +import type { FieldErrors, UseFormRegister } from 'react-hook-form' + +interface StudentIdSectionProps { + studentId: string + updateNow: boolean + isLoading: boolean + errors: FieldErrors + register: UseFormRegister + defaultProfileValues: { + studentId?: string + } +} + +export default function StudentIdSection({ + studentId, + updateNow, + isLoading, + errors, + register, + defaultProfileValues +}: StudentIdSectionProps) { + return ( + <> + + + {errors.studentId && ( +
+ {errors.studentId.message} +
+ )} + + ) +} diff --git a/apps/frontend/app/(main)/settings/_components/TopicSection.tsx b/apps/frontend/app/(main)/settings/_components/TopicSection.tsx new file mode 100644 index 0000000000..9afe01773d --- /dev/null +++ b/apps/frontend/app/(main)/settings/_components/TopicSection.tsx @@ -0,0 +1,12 @@ +export default function TopicSection({ updateNow }: { updateNow: boolean }) { + return ( + <> +

Settings

+

+ {updateNow + ? 'You must update your information' + : 'You can change your information'} +

+ + ) +} diff --git a/apps/frontend/app/(main)/settings/page.tsx b/apps/frontend/app/(main)/settings/page.tsx index 254f71fd08..2be3c790d2 100644 --- a/apps/frontend/app/(main)/settings/page.tsx +++ b/apps/frontend/app/(main)/settings/page.tsx @@ -1,45 +1,25 @@ 'use client' -import { Button } from '@/components/ui/button' -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList -} from '@/components/ui/command' -import { Input } from '@/components/ui/input' -import { - Popover, - PopoverContent, - PopoverTrigger -} from '@/components/ui/popover' -import { ScrollArea } from '@/components/ui/scroll-area' -import { majors } from '@/lib/constants' -import { cn, safeFetcher, safeFetcherWithAuth } from '@/lib/utils' -import invisible from '@/public/24_invisible.svg' -import visible from '@/public/24_visible.svg' -import codedangSymbol from '@/public/codedang-editor.svg' +import { safeFetcher, safeFetcherWithAuth } from '@/lib/utils' +import type { SettingsFormat } from '@/types/type' import { zodResolver } from '@hookform/resolvers/zod' -import type { Route } from 'next' -import type { NavigateOptions } from 'next/dist/shared/lib/app-router-context.shared-runtime' -import Image from 'next/image' import { useRouter, useSearchParams } from 'next/navigation' -import React, { useEffect } from 'react' +import { useEffect, useRef } from 'react' import { useState } from 'react' import { useForm } from 'react-hook-form' -import { FaCheck, FaChevronDown } from 'react-icons/fa6' import { toast } from 'sonner' import { z } from 'zod' - -interface SettingsFormat { - currentPassword: string - newPassword: string - confirmPassword: string - realName: string - studentId: string -} +import { useConfirmNavigation } from './_components/ConfirmNavigation' +import CurrentPwSection from './_components/CurrentPwSection' +import IdSection from './_components/IdSection' +import LogoSection from './_components/LogoSection' +import MajorSection from './_components/MajorSection' +import NameSection from './_components/NameSection' +import NewPwSection from './_components/NewPwSection' +import ReEnterNewPwSection from './_components/ReEnterNewPwSection' +import SaveButton from './_components/SaveButton' +import StudentIdSection from './_components/StudentIdSection' +import TopicSection from './_components/TopicSection' interface getProfile { username: string // ID @@ -81,20 +61,11 @@ const schemaSettings = (updateNow: boolean) => : z.string().optional() }) -function requiredMessage(message?: string) { - return ( -
- {message} -
- ) -} - export default function Page() { const searchParams = useSearchParams() const updateNow = searchParams.get('updateNow') const router = useRouter() - const [bypassConfirmation, setBypassConfirmation] = useState(false) - + const bypassConfirmation = useRef(false) const [defaultProfileValues, setdefaultProfileValues] = useState({ username: '', userProfile: { @@ -120,13 +91,15 @@ export default function Page() { fetchDefaultProfile() }, []) + useConfirmNavigation(bypassConfirmation, !!updateNow) + const { register, handleSubmit, getValues, setValue, watch, - formState: { errors, isDirty } + formState: { errors } } = useForm({ resolver: zodResolver(schemaSettings(!!updateNow)), mode: 'onChange', @@ -139,55 +112,6 @@ export default function Page() { } }) - // const beforeUnloadHandler = (event: BeforeUnloadEvent) => { - // // Recommended - // event.preventDefault() - - // // Included for legacy support, e.g. Chrome/Edge < 119 - // event.returnValue = true - // return true - // } - - /** - * Prompt the user with a confirmation dialog when they try to navigate away from the page. - */ - const useConfirmNavigation = () => { - useEffect(() => { - const originalPush = router.push - const newPush = ( - href: string, - options?: NavigateOptions | undefined - ): void => { - if (updateNow) { - if (!bypassConfirmation) { - toast.error('You must update your information') - } else { - originalPush(href as Route, options) - } - return - } - if (!bypassConfirmation) { - const isConfirmed = window.confirm( - 'Are you sure you want to leave?\nYour changes have not been saved.\nIf you leave this page, all changes will be lost.\nDo you still want to proceed?' - ) - if (isConfirmed) { - originalPush(href as Route, options) - } - return - } - originalPush(href as Route, options) - } - router.push = newPush - // window.onbeforeunload = beforeUnloadHandler - return () => { - router.push = originalPush - // window.onbeforeunload = null - } - }, [router, isDirty, bypassConfirmation]) - } - - useConfirmNavigation() - const [isCheckButtonClicked, setIsCheckButtonClicked] = useState(false) const [isPasswordCorrect, setIsPasswordCorrect] = useState(false) @@ -197,14 +121,15 @@ export default function Page() { const [confirmPasswordShow, setConfirmPasswordShow] = useState(false) const [majorOpen, setMajorOpen] = useState(false) const [majorValue, setMajorValue] = useState('') + const [isLoading, setIsLoading] = useState(true) + const currentPassword = watch('currentPassword') const newPassword = watch('newPassword') const confirmPassword = watch('confirmPassword') const realName = watch('realName') const studentId = watch('studentId') - const isPasswordsMatch = newPassword === confirmPassword && newPassword !== '' - const [isLoading, setIsLoading] = useState(true) + const isPasswordsMatch = newPassword === confirmPassword && newPassword !== '' const saveAblePassword: boolean = !!currentPassword && !!newPassword && @@ -221,7 +146,7 @@ export default function Page() { const saveAbleUpdateNow = !!studentId && majorValue !== 'none' && !errors.studentId - // New Password Input 창과 Re-enter Password Input 창의 border 색상을, 일치하는지 여부에 따라 바꿈 + // 일치 여부에 따라 New Password Input, Re-enter Password Input 창의 border 색상을 바꿈 useEffect(() => { if (isPasswordsMatch) { setValue('newPassword', newPassword) @@ -254,7 +179,7 @@ export default function Page() { }) if (response.ok) { toast.success('Successfully updated your information') - setBypassConfirmation(true) + bypassConfirmation.current = true setTimeout(() => { if (updateNow) { router.push('/') @@ -314,292 +239,92 @@ export default function Page() { return (
-
-
- codedang -

CODEDANG

-
-

Online Judge Platform for SKKU

-
+ {/* Logo */} + + {/* Form */}
-

Settings

-

- {updateNow - ? 'You must update your information' - : 'You can change your information'} -

- + {/* Topic */} + {/* ID */} - - - {/* Current password */} - -
-
- - setPasswordShow(!passwordShow)} - > - {passwordShow ? ( - visible - ) : ( - invisible - )} - -
- -
- {errors.currentPassword && - errors.currentPassword.message === 'Required' && - requiredMessage('Required')} - {!errors.currentPassword && - isCheckButtonClicked && - (isPasswordCorrect ? ( -
- Correct -
- ) : ( -
- Incorrect -
- ))} - + {/* New password */} -
-
- - setNewPasswordShow(!newPasswordShow)} - > - {newPasswordShow ? ( - visible - ) : ( - invisible - )} - -
-
- {errors.newPassword && newPasswordAble && ( -
-
    -
  • 8-20 characters
  • -
  • - Include two of the following: capital letters, small letters, - numbers -
  • -
-
- )} - + {/* Re-enter new password */} -
-
- { - if (watch('newPassword') != val) { - return 'Incorrect' - } - } - })} - className={`flex justify-stretch border-neutral-300 ring-0 placeholder:text-neutral-400 focus-visible:ring-0 disabled:bg-neutral-200 ${ - isPasswordsMatch - ? 'border-primary' - : confirmPassword && 'border-red-500' - } `} - /> - setConfirmPasswordShow(!confirmPasswordShow)} - > - {confirmPasswordShow ? ( - visible - ) : ( - invisible - )} - -
-
- {getValues('confirmPassword') && - (isPasswordsMatch ? ( -
- Correct -
- ) : ( -
- Incorrect -
- ))} - +
- {/* Name */} - - - {realName && - errors.realName && - requiredMessage(errors.realName.message)} - {/* Student ID */} - - + {/* Major */} + - {errors.studentId && requiredMessage(errors.studentId.message)} - - {/* First Major */} - -
- - - - - - - - - No major found. - - - {majors?.map((major) => ( - { - setMajorValue(currentValue) - setMajorOpen(false) - }} - > - - {major} - - ))} - - - - - - -
- {/* Save Button */} -
- -
+
) diff --git a/apps/frontend/types/type.ts b/apps/frontend/types/type.ts index 22a5d7f9fa..2b30fe3c21 100644 --- a/apps/frontend/types/type.ts +++ b/apps/frontend/types/type.ts @@ -169,3 +169,11 @@ export interface SubmissionDetail { updateTime: Date }[] } + +export interface SettingsFormat { + currentPassword: string + newPassword: string + confirmPassword: string + realName: string + studentId: string +}