Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions src/app/dashboard/[id]/Card/Card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,24 @@ import Image from 'next/image'

import type { Card } from '@/app/api/useCards'

import { useDragStore } from '../store/useDragStore'
import Tags from './Tags'

export default function Card({ card }: { card: Card }) {
const { imageUrl, title, tags, dueDate, assignee } = card
export default function Card({
card,
columnId,
}: {
card: Card
columnId: number
}) {
const { id, imageUrl, title, tags, dueDate, assignee } = card
const { setDraggingCard } = useDragStore()
return (
<div className="BG-white Border-section relative w-314 rounded-6 border-solid px-20 py-16">
<div
draggable="true"
onDragStart={() => setDraggingCard({ cardId: id, columnId: columnId })}
className="BG-white Border-section relative w-314 rounded-6 border-solid px-20 py-16"
>
Todo Card
{imageUrl && (
<Image
Expand All @@ -17,6 +29,7 @@ export default function Card({ card }: { card: Card }) {
height={600}
className="h-auto w-full rounded-6 object-contain"
priority
draggable="false"
/>
)}
<p>{title}</p>
Expand Down
36 changes: 34 additions & 2 deletions src/app/dashboard/[id]/Column/Column.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,48 @@
import Image from 'next/image'
import { useState } from 'react'

import useCards from '@/app/api/useCards'
import type { Column } from '@/app/api/useColumns'
import { cn } from '@/app/shared/lib/cn'

import { useCardMutation } from '../api/useCardMutation'
import Card from '../Card/Card'
import { useDragStore } from '../store/useDragStore'
export default function Column({ column }: { column: Column }) {
const { id, title }: { id: number; title: string } = column
const { data, isLoading, error } = useCards(id)
const [isDraggingover, setDraggingover] = useState(false)
const { clearDraggingCard } = useDragStore()
const cardMutation = useCardMutation()

if (isLoading) return <p>loading...</p>
if (error) return <p>error...{error.message}</p>

return (
<div className="BG-gray Border-column flex w-354 shrink-0 flex-col gap-16 p-20">
<div
onDragOver={(e) => {
e.preventDefault() //브라우저 기본은 드롭 비허용. 이걸 막아줘야 drop 가능
if (!isDraggingover) setDraggingover(true) //dragOver 이벤트 발생하는 내내 setState 실행 방지(처음 false일때만 setDraggingOver실행)
}}
onDragLeave={(e) => {
e.preventDefault()
if (isDraggingover) setDraggingover(false)
}}
onDrop={(e) => {
e.preventDefault()
if (isDraggingover) setDraggingover(false)
const draggingCard = useDragStore.getState().draggingCard
if (!draggingCard) return //null 방지
cardMutation.mutate({ cardId: draggingCard.cardId, columnId: id })
clearDraggingCard()
}}
className={cn(
'BG-gray Border-column flex w-354 shrink-0 flex-col gap-16 p-20',
{
'!border-blue-500': isDraggingover,
},
)}
>
<div className="mb-24 flex items-center justify-between">
<div className="flex items-center">
<div className="mb-7 mr-8 size-8 rounded-25 bg-blue-500"></div>
Expand All @@ -38,7 +68,9 @@ export default function Column({ column }: { column: Column }) {
/>
</div>
</button>
{data?.cards.map((card) => <Card key={card.id} card={card} />)}
{data?.cards.map((card) => (
<Card key={card.id} card={card} columnId={id} />
))}
</div>
)
}
12 changes: 12 additions & 0 deletions src/app/dashboard/[id]/api/updateCardColumn.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import axiosClient from '@/app/api/axiosClient'

// 카드 이동 - 해당 카드의 컬럼ID를 변경하는 방식(PUT)
export async function updateCardColumn(
cardId: number,
columnId: number,
): Promise<{ success: boolean }> {
const res = await axiosClient.put<{ success: boolean }>(`/cards/${cardId}`, {
columnId: columnId,
})
return res.data
}
60 changes: 60 additions & 0 deletions src/app/dashboard/[id]/api/useCardMutation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'

import type { CardResponse } from '@/app/api/useCards'

import { updateCardColumn } from './updateCardColumn'

export const useCardMutation = () => {
const queryClient = useQueryClient()

return useMutation({
// 1. 서버 API 호출
mutationFn: ({ cardId, columnId }: { cardId: number; columnId: number }) =>
updateCardColumn(cardId, columnId),

// 2. 낙관적 UI 처리 (서버 요청 전에 실행됨)
onMutate: async ({ cardId, columnId }) => {
await queryClient.cancelQueries({ queryKey: ['columnId'] })

// 업데이트 이전 데이터 챙겨뒀다가 롤백할때 사용
const previousData = queryClient.getQueryData<CardResponse>([
'columnId',
columnId,
])

Comment on lines +30 to +34
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

롤백 데이터가 대상 컬럼만 저장되어 불완전합니다

previousData 에서 원본·대상 두 컬럼을 모두 보관하지 않으면
서버 오류 시 원본 컬럼에 카드가 영영 사라질 수 있습니다.
onError 에서 두 컬럼을 모두 복구하도록 구조를 확장하세요.

Also applies to: 78-83

🤖 Prompt for AI Agents
In src/app/dashboard/[id]/api/useCardMutation.ts around lines 23 to 27 and also
lines 78 to 83, the rollback data only saves the target column, which is
incomplete. Modify the code to retrieve and store both the original and target
columns' data in previousData. Then update the onError handler to restore both
columns to ensure no data loss occurs if the server operation fails.

// 낙관적 업데이트(캐시 업데이트)
queryClient.setQueryData<CardResponse>(
['columnId', columnId],
(oldCards) => {
if (!oldCards) return undefined
return {
...oldCards,
cards: oldCards.cards.map((card) =>
card.id === cardId ? { ...card, columnId: columnId } : card,
),
}
},
)

return { previousData }
},
Copy link

@coderabbitai coderabbitai bot Jun 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

낙관적 UI 로직이 실질적으로 카드 이동을 반영하지 못합니다

  1. 대상 컬럼 캐시에 카드가 없을 경우 map 연산으로는 아무 변화가 없어 UI가 그대로입니다.
  2. 원본 컬럼에서 카드를 제거하지 않아 드래그 이후 두 컬럼에 동시에 표시될 가능성이 있습니다.
  3. cancelQueries / invalidateQueries 키가 ['columnId'] 로 뭉뚱그려져 있어 모든 컬럼을 불필요하게 무효화합니다.

최소한 다음과 같이 수정해야 합니다.

- await queryClient.cancelQueries({ queryKey: ['columnId'] })
+ await queryClient.cancelQueries({ queryKey: ['columnId', columnId] })

 ...

- const previousData = queryClient.getQueryData<CardResponse>([
-   'columnId',
-   columnId,
- ])
+ const previousTarget = queryClient.getQueryData<CardResponse>([
+   'columnId',
+   columnId,
+ ])
+ // 드래그 전 컬럼도 기억해야 롤백 가능
+ const previousSource = queryClient
+   .getQueryData<CardResponse>(['columnId', sourceColumnId])

 ...

- queryClient.setQueryData<CardResponse>(
-   ['columnId', columnId],
-   (oldCards) => {
-     if (!oldCards) return undefined
-     return {
-       ...oldCards,
-       cards: oldCards.cards.map((card) =>
-         card.id === cardId ? { ...card, columnId: columnId } : card,
-       ),
-     }
-   },
- )
+ // 1) 대상 컬럼에 카드 추가
+ queryClient.setQueryData<CardResponse>(['columnId', columnId], (old) => {
+   if (!old) return old
+   const movedCard =
+     old.cards.find((c) => c.id === cardId) ??
+     ({ ...variables.card, columnId } as Card) // fallback
+   return { ...old, cards: [...old.cards, movedCard] }
+ })
+
+ // 2) 원본 컬럼에서 카드 제거
+ queryClient.setQueryData<CardResponse>(['columnId', sourceColumnId], (old) => {
+   if (!old) return old
+   return { ...old, cards: old.cards.filter((c) => c.id !== cardId) }
+ })

 return { previousTarget, previousSource }

타입·변수(sourceColumnId, variables.card)는 실제 구현에 맞게 조정하세요.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In src/app/dashboard/[id]/api/useCardMutation.ts lines 16 to 40, the optimistic
UI update does not properly reflect card movement because it only updates the
target column cache without removing the card from the source column, and it
uses a generic query key that invalidates all columns unnecessarily. Fix this by
updating both the source and target column caches: remove the card from the
source column's cache and add it to the target column's cache. Also, replace the
generic query key ['columnId'] in cancelQueries and invalidateQueries with
specific keys for the affected columns to avoid unnecessary cache invalidation.

Copy link
Contributor Author

@dkslel1225 dkslel1225 Jun 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

카드의 컬럼 id를 바꾸는 방식으로 작동하기 때문에, 원래 컬럼에서 카드를 제거할 필요는 없을것 같지 않아?
1,3은 반영해야겠다.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dkslel1225 네, 이해했습니다. 말씀하신 대로 서버 응답 이후에 다시 fetch 해 정확한 상태를 맞춘다면 원본 컬럼 캐시를 직접 건드리지 않아도 동작에는 문제가 없습니다. 다만 몇 가지만 더 확인해 보시면 좋을 것 같아요.

  1. UX 이슈
    • 네트워크 지연이 있는 환경에서는 드래그 직후 순간적으로 두 컬럼에 카드가 동시 표시될 수 있습니다. 사용자가 눈치채기 힘들 정도라면 괜찮지만, 실사용-latency(모바일 LTE 등)에서 한 번 체크해 보시면 좋겠습니다.

  2. 롤백 안정성
    onError 에서 target 컬럼만 롤백하면, 낙관적으로 추가됐던 카드가 그대로 남을 수 있습니다. 최소한 target 컬럼의 previousData만 아니라 source 컬럼의 previousData도 같이 저장해 두면 더 안전합니다.

  3. 구현 팁 (1, 3 반영용 예시)

// 1️⃣ 대상 컬럼만 선택적으로 cancel / invalidate
await queryClient.cancelQueries({ queryKey: ['columnId', columnId] });
// ...
queryClient.invalidateQueries({ queryKey: ['columnId', columnId] });

// 2️⃣ 대상 컬럼 캐시에 카드가 없을 때 push
queryClient.setQueryData<CardResponse>(['columnId', columnId], old => {
  if (!old) return undefined;
  const exists = old.cards.some(c => c.id === cardId);
  return exists ? old : { ...old, cards: [...old.cards, { ...variables.card, columnId }] };
});

중복 표시나 롤백 시나리오가 실제로 문제되지 않는다면 지금 계획대로 진행하셔도 됩니다. 추가 논의가 필요하면 언제든 말씀 주세요!


// 3. 에러 발생 시 롤백
onError: (error, variables, context) => {
if (context?.previousData) {
queryClient.setQueryData<CardResponse>(
['columnId', variables.columnId],
context.previousData,
)
}
console.error('카드 이동 실패:', error)
},

// 4. 성공 시 서버 기준으로 다시 데이터 불러오도록 유도함.
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['columnId'],
})
},
})
}
2 changes: 1 addition & 1 deletion src/app/dashboard/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export default function DashboardID() {
return (
<>
<div className="fixed left-0 h-1080 w-300 bg-gray-100">사이드바</div>
<div className="ml-300">
<div className="ml-300 select-none">
<div className="flex">
{columns?.map((column) => <Column key={column.id} column={column} />)}
<div className="BG-gray Border-column p-20">
Expand Down
12 changes: 12 additions & 0 deletions src/app/dashboard/[id]/store/useDragStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { create } from 'zustand'

interface DragStore {
draggingCard: { cardId: number; columnId: number } | null
setDraggingCard: (data: { cardId: number; columnId: number }) => void
clearDraggingCard: () => void
}
export const useDragStore = create<DragStore>((set) => ({
draggingCard: null,
setDraggingCard: (data) => set({ draggingCard: data }),
clearDraggingCard: () => set({ draggingCard: null }),
}))