From 6dfe43b566a2718ea3d11b003aa9d3eba3606654 Mon Sep 17 00:00:00 2001 From: Yeonjin Kim Date: Thu, 14 May 2026 13:47:22 +0900 Subject: [PATCH 01/23] =?UTF-8?q?feat:=20=EC=B9=9C=EA=B5=AC=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20API=20=EC=97=B0=EB=8F=99=20=EB=B0=8F=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20=EC=95=84=EC=9D=B4=EB=94=94=20=EA=B4=80=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Calendar/hooks/useCalendarApiEvents.ts | 1 + .../utils/helpers/calendarPageHelpers.ts | 1 + src/shared/api/friend/api.ts | 15 +++ src/shared/api/friend/queryKeys.ts | 10 ++ src/shared/api/queryKeys/index.ts | 1 + .../hooks/form/useScheduleFormFields.ts | 2 + src/shared/hooks/query/useFriendQueries.ts | 7 ++ src/shared/schemas/schedule.ts | 1 + src/shared/types/calendar/types.ts | 1 + src/shared/types/friend/types.ts | 5 + .../ScheduleEditor/ShareSchedulePanel.tsx | 31 ++++- .../SearchFriend/SearchFriend.tsx | 117 ++++++++++-------- 12 files changed, 137 insertions(+), 55 deletions(-) create mode 100644 src/shared/api/friend/api.ts create mode 100644 src/shared/api/friend/queryKeys.ts create mode 100644 src/shared/hooks/query/useFriendQueries.ts create mode 100644 src/shared/types/friend/types.ts diff --git a/src/features/Calendar/hooks/useCalendarApiEvents.ts b/src/features/Calendar/hooks/useCalendarApiEvents.ts index 78b2e62..6b2c375 100644 --- a/src/features/Calendar/hooks/useCalendarApiEvents.ts +++ b/src/features/Calendar/hooks/useCalendarApiEvents.ts @@ -48,6 +48,7 @@ const toTodoEvent = (todo: TodoType): CalendarEvent => { isAllDay: todo.isAllDay, color: todo.color ?? 'GRAY', recurrenceGroup: null, + friendIds: [], type: 'todo', isDone: todo.isCompleted, isRecurring: todo.isRecurring, diff --git a/src/features/Calendar/utils/helpers/calendarPageHelpers.ts b/src/features/Calendar/utils/helpers/calendarPageHelpers.ts index f96a282..7ca43ed 100644 --- a/src/features/Calendar/utils/helpers/calendarPageHelpers.ts +++ b/src/features/Calendar/utils/helpers/calendarPageHelpers.ts @@ -32,6 +32,7 @@ export const createEvent = (date: Date, index: number, allDay = false): Calendar isAllDay: allDay, color: 'GRAY', recurrenceGroup: null, + friendIds: [], type: 'schedule', } } diff --git a/src/shared/api/friend/api.ts b/src/shared/api/friend/api.ts new file mode 100644 index 0000000..7b3546a --- /dev/null +++ b/src/shared/api/friend/api.ts @@ -0,0 +1,15 @@ +import type { TCommonResponse } from '@/shared/types/common/common' +import type { FriendDetail } from '@/shared/types/friend/types' + +import axiosInstance from '../axios' + +export const getFriendSearch = async ( + keyword: string, +): Promise> => { + const { data } = await axiosInstance.get('/friends/search', { + params: { + keyword, + }, + }) + return data +} diff --git a/src/shared/api/friend/queryKeys.ts b/src/shared/api/friend/queryKeys.ts new file mode 100644 index 0000000..e0d1d1d --- /dev/null +++ b/src/shared/api/friend/queryKeys.ts @@ -0,0 +1,10 @@ +import { createQueryKeys } from '@lukemorales/query-key-factory' + +import { getFriendSearch } from './api' + +export const friendKeys = createQueryKeys('friend', { + search: (keyword: string) => ({ + queryKey: [{ keyword }], + queryFn: () => getFriendSearch(keyword), + }), +}) diff --git a/src/shared/api/queryKeys/index.ts b/src/shared/api/queryKeys/index.ts index fa1198c..e244ad3 100644 --- a/src/shared/api/queryKeys/index.ts +++ b/src/shared/api/queryKeys/index.ts @@ -1,2 +1,3 @@ export * from '../calendar/queryKeys' +export * from '../friend/queryKeys' export * from '../todo/queryKeys' diff --git a/src/shared/hooks/form/useScheduleFormFields.ts b/src/shared/hooks/form/useScheduleFormFields.ts index be0903f..cd0b831 100644 --- a/src/shared/hooks/form/useScheduleFormFields.ts +++ b/src/shared/hooks/form/useScheduleFormFields.ts @@ -96,6 +96,7 @@ const buildScheduleDefaultValues = ({ isAllday: draftValues?.isAllday ?? initialIsAllDay, eventColor: draftValues?.eventColor ?? initialColor, repeatConfig: draftValues?.repeatConfig ?? initialRepeatConfig, + friendIds: initialEvent?.friendIds ?? [], } } @@ -140,6 +141,7 @@ export const useScheduleFormFields = ({ register('isAllday') register('repeatConfig') register('eventColor') + register('friendIds') }, [register]) useEffect(() => { diff --git a/src/shared/hooks/query/useFriendQueries.ts b/src/shared/hooks/query/useFriendQueries.ts new file mode 100644 index 0000000..3fa0399 --- /dev/null +++ b/src/shared/hooks/query/useFriendQueries.ts @@ -0,0 +1,7 @@ +import { friendKeys } from '@/shared/api/queryKeys' +import { useCustomQuery } from '@/shared/hooks/common/customQuery' + +export function useFriendSearchQuery(keyword: string, enabled = true) { + const query = friendKeys.search(keyword) + return useCustomQuery(query.queryKey, query.queryFn, { enabled }) +} diff --git a/src/shared/schemas/schedule.ts b/src/shared/schemas/schedule.ts index 11ff267..2885df4 100644 --- a/src/shared/schemas/schedule.ts +++ b/src/shared/schemas/schedule.ts @@ -57,4 +57,5 @@ export const addScheduleSchema = yup.object().shape({ isAllday, eventColor, repeatConfig: repeatConfigSchema, + friendIds: yup.array().of(yup.number().required()).default([]).defined(), }) diff --git a/src/shared/types/calendar/types.ts b/src/shared/types/calendar/types.ts index 72242ee..d53fd04 100644 --- a/src/shared/types/calendar/types.ts +++ b/src/shared/types/calendar/types.ts @@ -15,6 +15,7 @@ export type Event = { color: EventColorType recurrenceGroup: RecurrenceGroup | null isShared?: boolean + friendIds: number[] } export type CalendarEvent = Omit & { diff --git a/src/shared/types/friend/types.ts b/src/shared/types/friend/types.ts new file mode 100644 index 0000000..53f8dbd --- /dev/null +++ b/src/shared/types/friend/types.ts @@ -0,0 +1,5 @@ +export type FriendDetail = { + id: number + opponentName: string + opponentEmail: string +} diff --git a/src/shared/ui/Modals/ScheduleEditor/ShareSchedulePanel.tsx b/src/shared/ui/Modals/ScheduleEditor/ShareSchedulePanel.tsx index 24ec701..de45440 100644 --- a/src/shared/ui/Modals/ScheduleEditor/ShareSchedulePanel.tsx +++ b/src/shared/ui/Modals/ScheduleEditor/ShareSchedulePanel.tsx @@ -1,7 +1,9 @@ import { useEffect, useState } from 'react' +import { useFormContext, useWatch } from 'react-hook-form' import Arrow from '@/shared/assets/icons/chevron.svg?react' import Close from '@/shared/assets/icons/close.svg?react' +import type { ScheduleEditorFormValues } from '@/shared/types/event/event' import type { ScheduleShareFriend } from '@/shared/types/schedule/shareFriend' import SearchFriend from '@/shared/ui/scheduleTodo/SearchFriend/SearchFriend' @@ -12,17 +14,28 @@ type ShareSchedulePanelProps = { } const ShareSchedulePanel = ({ onSharedChange }: ShareSchedulePanelProps) => { + const { control, setValue } = useFormContext() const [isOpen, setIsOpen] = useState(false) const [selectedFriends, setSelectedFriends] = useState([]) + const selectedFriendIds = useWatch({ control, name: 'friendIds' }) ?? [] useEffect(() => { - onSharedChange?.(selectedFriends.length > 0) - }, [onSharedChange, selectedFriends.length]) + onSharedChange?.(selectedFriendIds.length > 0) + }, [onSharedChange, selectedFriendIds.length]) const handleToggleFriend = (friend: ScheduleShareFriend) => { - setSelectedFriends((previous) => { - const isSelected = previous.some((selectedFriend) => selectedFriend.userId === friend.userId) + const friendId = Number(friend.userId) + const isSelected = selectedFriendIds.includes(friendId) + + setValue( + 'friendIds', + isSelected + ? selectedFriendIds.filter((selectedFriendId) => selectedFriendId !== friendId) + : [...selectedFriendIds, friendId], + { shouldDirty: true, shouldValidate: true }, + ) + setSelectedFriends((previous) => { if (isSelected) { return previous.filter((selectedFriend) => selectedFriend.userId !== friend.userId) } @@ -32,6 +45,12 @@ const ShareSchedulePanel = ({ onSharedChange }: ShareSchedulePanelProps) => { } const handleRemoveFriend = (friendId: ScheduleShareFriend['userId']) => { + const numericFriendId = Number(friendId) + setValue( + 'friendIds', + selectedFriendIds.filter((selectedFriendId) => selectedFriendId !== numericFriendId), + { shouldDirty: true, shouldValidate: true }, + ) setSelectedFriends((previous) => previous.filter((friend) => friend.userId !== friendId)) } @@ -41,7 +60,7 @@ const ShareSchedulePanel = ({ onSharedChange }: ShareSchedulePanelProps) => { type="button" onClick={() => setIsOpen(!isOpen)} isOpen={isOpen} - isShared={selectedFriends.length > 0} + isShared={selectedFriendIds.length > 0} >
@@ -51,7 +70,7 @@ const ShareSchedulePanel = ({ onSharedChange }: ShareSchedulePanelProps) => { {isOpen && ( - +
{selectedFriends.map((friend) => ( diff --git a/src/shared/ui/scheduleTodo/SearchFriend/SearchFriend.tsx b/src/shared/ui/scheduleTodo/SearchFriend/SearchFriend.tsx index cf69d2c..c26207a 100644 --- a/src/shared/ui/scheduleTodo/SearchFriend/SearchFriend.tsx +++ b/src/shared/ui/scheduleTodo/SearchFriend/SearchFriend.tsx @@ -4,36 +4,47 @@ import { createPortal } from 'react-dom' import Close from '@/shared/assets/icons/close.svg?react' import Plus from '@/shared/assets/icons/plus.svg?react' import Search from '@/shared/assets/icons/search.svg?react' -import { MOCK_SCHEDULE_SHARE_FRIENDS } from '@/shared/constants/scheduleShareFriend' +import { useThrottledValue } from '@/shared/hooks/common/useThrottledValue' +import { useFriendSearchQuery } from '@/shared/hooks/query/useFriendQueries' import { theme } from '@/shared/styles' import type { ScheduleShareFriend } from '@/shared/types/schedule/shareFriend' import { isElementVisible } from '@/shared/utils/domVisibility' -import { filterScheduleShareFriends } from '@/shared/utils/scheduleShareFriend' import * as S from './SearchFriend.style' type SearchFriendProps = { - selectedFriends: ScheduleShareFriend[] + selectedFriendIds: number[] onToggleFriend: (friend: ScheduleShareFriend) => void } -const SearchFriend = ({ selectedFriends, onToggleFriend }: SearchFriendProps) => { +const SearchFriend = ({ selectedFriendIds, onToggleFriend }: SearchFriendProps) => { const [keyword, setKeyword] = useState('') const [isOpen, setIsOpen] = useState(false) const [resultPosition, setResultPosition] = useState({ left: 0, top: 0, width: 0 }) const inputWrapperRef = useRef(null) const resultRef = useRef(null) - const selectedFriendIds = useMemo( - () => new Set(selectedFriends.map((friend) => friend.userId)), - [selectedFriends], - ) - const trimmedKeyword = keyword.trim().toLowerCase() - const filteredFriends = useMemo( - () => filterScheduleShareFriends(MOCK_SCHEDULE_SHARE_FRIENDS, trimmedKeyword), - [trimmedKeyword], + const selectedFriendIdSet = useMemo(() => new Set(selectedFriendIds), [selectedFriendIds]) + const trimmedKeyword = keyword.trim() + const throttledKeyword = useThrottledValue(trimmedKeyword, 300) + const { + data: friendSearchData, + isError, + isFetching, + } = useFriendSearchQuery(throttledKeyword, Boolean(throttledKeyword)) + const searchedFriends = useMemo( + () => + (friendSearchData?.result.friendDetailList ?? []).map( + (friend): ScheduleShareFriend => ({ + userId: String(friend.id), + userName: friend.opponentName, + email: friend.opponentEmail, + }), + ), + [friendSearchData?.result.friendDetailList], ) - const shouldShowResult = isOpen && Boolean(trimmedKeyword) && filteredFriends.length > 0 + const shouldShowResult = isOpen && Boolean(trimmedKeyword) + const isWaitingForThrottledSearch = trimmedKeyword !== throttledKeyword || isFetching const updateResultPosition = () => { const inputWrapper = inputWrapperRef.current @@ -58,41 +69,49 @@ const SearchFriend = ({ selectedFriends, onToggleFriend }: SearchFriendProps) => return createPortal( - {filteredFriends.map((friend) => { - const isSelected = selectedFriendIds.has(friend.userId) - - return ( - onToggleFriend(friend)} - > - {friend.userName} -
- {friend.email} - {isSelected ? ( -