-
Notifications
You must be signed in to change notification settings - Fork 39
[배수민] sprint10 #248
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
The head ref may contain hidden characters: "Next-\uBC30\uC218\uBBFC-sprint10"
[배수민] sprint10 #248
Changes from 5 commits
a3f1083
f45ccd0
c5e883c
40ef167
1bce22e
d23af23
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,121 @@ | ||
| 'use client'; | ||
|
|
||
| import { useEffect, useState } from 'react'; | ||
|
|
||
| import { useMutation, useQuery } from '@tanstack/react-query'; | ||
| import { useRouter } from 'next/navigation'; | ||
|
|
||
| import Button from '@/components/Button'; | ||
| import CheckListDetail from '@/components/CheckListDetail'; | ||
| import ImageUploader from '@/components/ImageUploader'; | ||
| import LoadingOverlay from '@/components/LoadingOverlay'; | ||
| import Memo from '@/components/Memo'; | ||
| import { useItemStore } from '@/store/itemStore'; | ||
| import { UploadImageResponse } from '@/types/TodoTypes'; | ||
|
|
||
| import { deleteItem, getItem, updateItem, uploadImage } from '../api/todo'; | ||
|
|
||
| interface ItemDetailContentProps { | ||
| itemId: number; | ||
| } | ||
|
|
||
| const ItemDetailContent = ({ itemId }: ItemDetailContentProps) => { | ||
| const router = useRouter(); | ||
| const { data, isLoading, isFetching } = useQuery({ | ||
| queryKey: ['itemDetail', itemId], | ||
| queryFn: () => getItem(itemId), | ||
| }); | ||
|
|
||
| const updateItemMutation = useMutation({ | ||
| mutationFn: () => updateItem(itemId, detailData), | ||
| retry: 1, | ||
| retryDelay: 300, | ||
| onSuccess: () => { | ||
| setDetailData({ name: '', memo: '', imageUrl: '', isCompleted: false }); | ||
| router.push('/'); | ||
| }, | ||
| onError: (error) => { | ||
| console.log(error); | ||
| }, | ||
| }); | ||
|
|
||
| const uploadImageMutation = useMutation<UploadImageResponse, Error, File>({ | ||
| mutationFn: (file: File) => uploadImage(file), | ||
| retry: 1, | ||
| retryDelay: 300, | ||
| onSuccess: (response) => { | ||
| const { url } = response; | ||
| console.log(url); | ||
| setDetailData({ imageUrl: url }); | ||
| updateItemMutation.mutate(); | ||
| }, | ||
| onError: (error) => { | ||
| console.log(error); | ||
| }, | ||
| }); | ||
|
|
||
| const deleteItemMutation = useMutation({ | ||
| mutationFn: () => deleteItem(itemId), | ||
| retry: 1, | ||
| retryDelay: 300, | ||
| onSuccess: () => { | ||
| setDetailData({ name: '', memo: '', imageUrl: '', isCompleted: false }); | ||
| router.push('/'); | ||
| }, | ||
| onError: (error) => { | ||
| console.log(error); | ||
| }, | ||
| }); | ||
|
|
||
| const { detailData, setDetailData } = useItemStore(); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 리액트 쿼리를 사용하고있어 쿼리 키 기반 캐싱을 활용해 상세 데이터를 조회하는 쿼리를 필요한 컴포넌트에서 직접 작성하는게 좋을것같은데, 별도의 상태 관리가 필요한 이유가 있을까요~? |
||
|
|
||
| const [file, setFile] = useState<File | null>(null); | ||
|
|
||
| const disableEditButton = | ||
| !detailData.name || | ||
| (detailData.isCompleted === data?.isCompleted && | ||
| detailData.name === data?.name && | ||
| detailData.memo === data?.memo && | ||
| !file); | ||
|
|
||
| const onUploadFile = (fileData: File) => { | ||
| setFile(fileData); | ||
| }; | ||
|
|
||
| const onClickEditDetail = () => { | ||
| if (file) { | ||
| uploadImageMutation.mutate(file); | ||
| } else { | ||
| updateItemMutation.mutate(); | ||
| } | ||
| }; | ||
|
|
||
| useEffect(() => { | ||
| if (data) { | ||
| const { name, memo, imageUrl, isCompleted } = data; | ||
| setDetailData({ name, memo, imageUrl, isCompleted }); | ||
| } | ||
| }, [data, setDetailData]); | ||
|
Comment on lines
+92
to
+97
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 별도의 상태관리가 필요하지않다면 이 effect도 없앨 수 있을 것 같아요 :) |
||
|
|
||
| if (isLoading || isFetching) return <LoadingOverlay />; | ||
|
|
||
| return ( | ||
| <div className='max-w-[75rem] w-full flex flex-col gap-6.5 bg-white px-12 sm:px-24.5 py-6'> | ||
| <CheckListDetail /> | ||
| <div className='flex flex-col sm:flex-row items-start justify-center gap-6'> | ||
| <ImageUploader imageUrl={detailData.imageUrl} onUpload={onUploadFile} /> | ||
| <Memo /> | ||
| </div> | ||
| <div className='flex w-full justify-center sm:justify-end gap-4 min-w-0'> | ||
| <Button mode='edit' size='full' disabled={disableEditButton} onClick={onClickEditDetail}> | ||
| 수정 완료 | ||
| </Button> | ||
| <Button mode='delete' size='full' onClick={() => deleteItemMutation.mutate()}> | ||
| 삭제하기 | ||
| </Button> | ||
| </div> | ||
| </div> | ||
| ); | ||
| }; | ||
|
|
||
| export default ItemDetailContent; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| import { getItem } from '@/app/api/todo'; | ||
| import ItemDetailContent from '@/app/components/ItemDetailContent'; | ||
| import HydrationWrapper from '@/components/HydrationWrapper'; | ||
|
|
||
| interface ItemPageProps { | ||
| params: Promise<{ itemId: string }>; | ||
| searchParams?: Promise<{ [key: string]: string | string[] | undefined }>; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. id의 경우 string이 아닌 number타입으로 들어가는 경우도 있을까요? |
||
| } | ||
| const ItemPage = async ({ params }: ItemPageProps) => { | ||
| const { itemId } = await params; | ||
| return ( | ||
| <HydrationWrapper | ||
| prefetchQueries={[ | ||
| { queryKey: ['itemDetail', itemId], queryFn: () => getItem(Number(itemId)) }, | ||
| ]} | ||
| > | ||
| <div className='flex justify-center bg-gray-50 min-h-[calc(100vh-3.75rem)]'> | ||
| <ItemDetailContent itemId={Number(itemId)} /> | ||
| </div> | ||
| </HydrationWrapper> | ||
| ); | ||
| }; | ||
|
|
||
| export default ItemPage; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,81 @@ | ||
| 'use client'; | ||
| import { useState } from 'react'; | ||
|
|
||
| import clsx from 'clsx'; | ||
|
|
||
| import Check from '@/assets/icons/check.svg'; | ||
| import { useItemStore } from '@/store/itemStore'; | ||
|
|
||
| interface CheckListDetailProps { | ||
| className?: string; | ||
| } | ||
|
|
||
| const CheckListDetail = ({ className }: CheckListDetailProps) => { | ||
| const [editMode, setEditMode] = useState(false); | ||
| const { detailData, setDetailData } = useItemStore(); | ||
|
|
||
| const onChangeCheck = (e: React.ChangeEvent<HTMLInputElement>) => { | ||
| e.stopPropagation(); | ||
| setDetailData({ isCompleted: !detailData.isCompleted }); | ||
| }; | ||
|
|
||
| const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => { | ||
| setDetailData({ name: e.target.value }); | ||
| }; | ||
|
|
||
| const onClickDetail = () => { | ||
| setEditMode(true); | ||
| }; | ||
|
|
||
| const Checkbox = () => { | ||
| return ( | ||
| <label | ||
| className='flex items-center cursor-pointer select-none' | ||
| onClick={(e) => e.stopPropagation()} | ||
| > | ||
| <input | ||
| type='checkbox' | ||
| className='hidden' | ||
| checked={detailData.isCompleted} | ||
| onChange={onChangeCheck} | ||
| /> | ||
| <div | ||
| className={`${detailData.isCompleted ? 'border-none bg-violet-600' : 'border-slate-900 border-2 bg-yellow-50'} w-8 h-8 rounded-full border-gray-400 flex items-center justify-center | ||
| `} | ||
| > | ||
| {detailData.isCompleted && <Check stroke='white' width={16} height={16} />} | ||
| </div> | ||
| </label> | ||
| ); | ||
| }; | ||
|
|
||
| return ( | ||
| <div | ||
| className={clsx( | ||
| 'w-full border-2 border-slate-900 rounded-[1.5rem] px-3 py-3.5 flex gap-4 items-center justify-center', | ||
| detailData.isCompleted ? 'bg-violet-100' : 'bg-white', | ||
| className, | ||
| )} | ||
| onClick={onClickDetail} | ||
| > | ||
| <Checkbox /> | ||
| {editMode ? ( | ||
| <input | ||
| value={detailData.name} | ||
| onChange={handleNameChange} | ||
| className='focus:outline-none' | ||
| style={{ width: `${detailData.name.length + 1.4 || 1}ch` }} | ||
| onKeyDown={(e) => { | ||
| if (e.key === 'Enter') { | ||
| setEditMode(false); | ||
| } | ||
| }} | ||
| /> | ||
| ) : ( | ||
| <span className='truncate underline'>{detailData.name}</span> | ||
| )} | ||
|
Comment on lines
+62
to
+76
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. CheckListDetail 컴포넌트에서 체크 리스트 이름 수정 시 length를 통해 길이를 맞추려고 하였는데, 더 좋은 방법이 있을까요? 읽기 모드일때는 span으로 보여주고, 수정 모드일때는 input으로 보여주다보니 레이아웃이 틀어지는 것 같습니다.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 고정 너비를 가진 컨테이너로 감싸서 input과 span이 동일한 공간을 차지하도록해주면 해결될것같네요! :) 너비를 고정하기 애매하다면, |
||
| </div> | ||
| ); | ||
| }; | ||
|
|
||
| export default CheckListDetail; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
data fetching 로직이 길어지는데 따로 로직만 분리해 커스텀 훅을 만들어 관리해보는건 어떨까요?