diff --git a/src/components/user/auth-form/LinkContainer.tsx b/src/components/user/auth-form/LinkContainer.tsx index e912fcf6..60194979 100644 --- a/src/components/user/auth-form/LinkContainer.tsx +++ b/src/components/user/auth-form/LinkContainer.tsx @@ -2,25 +2,45 @@ import { ChangeEvent, useState } from 'react'; import { FaPlus, FaMinus } from 'react-icons/fa6'; import { useFormContext } from 'react-hook-form'; import { USER_SETTINGS } from '@constants/settings'; +import Spinner from '@components/common/Spinner'; import useToast from '@hooks/useToast'; +import { useUpdateLinks } from '@hooks/query/useUserQuery'; +import useStore from '@stores/useStore'; +import { EditUserLinksForm } from '@/types/UserType'; type LinkContainerProps = { initialLinks: string[]; + isImmediateUpdate: boolean; }; -export default function LinkContainer({ initialLinks }: LinkContainerProps) { - const { setValue } = useFormContext(); +export default function LinkContainer({ initialLinks, isImmediateUpdate }: LinkContainerProps) { + const { setValue, watch } = useFormContext(); + const { editUserInfo } = useStore(); const [link, setLink] = useState(''); - const [links, setLinks] = useState(initialLinks); const [isFocused, setIsFocused] = useState(false); const { toastWarn } = useToast(); + const links: string[] = watch('links', initialLinks); + + const { mutate: updateLinksMutate, isPending: updateLinksIsPending } = useUpdateLinks(); + const handleFocus = () => setIsFocused(true); const handleBlur = () => setIsFocused(false); const handleLinkChange = (e: ChangeEvent) => setLink(e.target.value); + // TODO: 링크 업데이트 후작업 처리 방법 고민해 보기 + const handleUpdateLinks = (userLinks: EditUserLinksForm) => { + updateLinksMutate(userLinks, { + onSuccess: () => { + setValue('links', userLinks.links); + setLink(''); + editUserInfo(userLinks); + }, + }); + }; + const handleAddLink = (newLink: string) => { if (newLink.trim() === '') return; if (links.length === USER_SETTINGS.MAX_LINK_COUNT) { @@ -32,19 +52,30 @@ export default function LinkContainer({ initialLinks }: LinkContainerProps) { if (isIncludedLink) return toastWarn('이미 등록된 링크입니다.'); const updatedLinks = [...links, newLink.trim()]; - setLinks(updatedLinks); + + if (isImmediateUpdate) return handleUpdateLinks({ links: updatedLinks }); + setValue('links', updatedLinks); setLink(''); }; const handleRemoveLink = (removeLink: string) => { const filteredData = links.filter((linkItem) => linkItem !== removeLink); - setLinks(filteredData); + + if (isImmediateUpdate) return handleUpdateLinks({ links: filteredData }); + setValue('links', filteredData); }; return ( -
+
+ {updateLinksIsPending && ( +
+ + + +
+ )} @@ -81,7 +112,8 @@ export default function LinkContainer({ initialLinks }: LinkContainerProps) { onBlur={handleBlur} onChange={handleLinkChange} type="text" - // TODO: 전체적으로 인풋 관련 스타일링 수정 필요, div 전체를 input이 덮을 수 있도록 수정... + // TODO: 전체적으로 인풋 관련 스타일링 수정 필요, div 전체를 input이 덮을 수 있도록 수정 + disabled={updateLinksIsPending} className="h-full grow bg-inherit outline-none placeholder:text-emphasis" /> diff --git a/src/hooks/query/useUserQuery.ts b/src/hooks/query/useUserQuery.ts index 79c17a7d..499c3002 100644 --- a/src/hooks/query/useUserQuery.ts +++ b/src/hooks/query/useUserQuery.ts @@ -1,8 +1,9 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import useToast from '@hooks/useToast'; -import { updateUserInfo } from '@services/userService'; -import { generateUserInfoQueryKey } from '@/utils/queryKeyGenerator'; -import type { EditUserInfoRequest } from '@/types/UserType'; +import { updateLinks, updateUserInfo } from '@services/userService'; +import { generateLinksQueryKey, generateUserInfoQueryKey } from '@utils/queryKeyGenerator'; +import useStore from '@stores/useStore'; +import type { EditUserInfoRequest, EditUserLinksForm } from '@/types/UserType'; export function useUpdateUserInfo() { const queryClient = useQueryClient(); @@ -20,3 +21,21 @@ export function useUpdateUserInfo() { return mutation; } + +export function useUpdateLinks() { + const { userInfo } = useStore(); + const queryClient = useQueryClient(); + const { toastSuccess, toastError } = useToast(); + const linksQueryKey = generateLinksQueryKey(userInfo.userId); + + const mutation = useMutation({ + mutationFn: (data: EditUserLinksForm) => updateLinks(data), + onError: () => toastError('링크 수정에 실패했습니다. 다시 시도해 주세요.'), + onSuccess: () => { + toastSuccess('링크가 수정되었습니다.'); + queryClient.invalidateQueries({ queryKey: linksQueryKey }); + }, + }); + + return mutation; +} diff --git a/src/mocks/services/userServiceHandler.ts b/src/mocks/services/userServiceHandler.ts index 1228b52d..f00ea5e8 100644 --- a/src/mocks/services/userServiceHandler.ts +++ b/src/mocks/services/userServiceHandler.ts @@ -4,7 +4,7 @@ import { NICKNAME_REGEX } from '@constants/regex'; import { convertTokenToUserId } from '@utils/converter'; import type { Team } from '@/types/TeamType'; import type { Role } from '@/types/RoleType'; -import type { EditUserInfoForm, User } from '@/types/UserType'; +import type { EditUserInfoForm, EditUserLinksForm, User } from '@/types/UserType'; const BASE_URL = import.meta.env.VITE_BASE_URL; @@ -51,6 +51,35 @@ const userServiceHandler = [ return HttpResponse.json(userInfo, { status: 200 }); }), + // 링크 변경 API + http.patch(`${BASE_URL}/user/links`, async ({ request }) => { + const accessToken = request.headers.get('Authorization'); + if (!accessToken) return new HttpResponse(null, { status: 401 }); + + const { links } = (await request.json()) as EditUserLinksForm; + + let userId; + // ToDo: 추후 삭제 + if (accessToken === JWT_TOKEN_DUMMY) { + const payload = JWT_TOKEN_DUMMY.split('.')[1]; + userId = Number(payload.replace('mocked-payload-', '')); + } else { + // 토큰에서 userId 추출 + userId = convertTokenToUserId(accessToken); + } + + const userIndex = userId ? USER_DUMMY.findIndex((user) => user.userId === userId) : -1; + + if (userIndex === -1) { + return HttpResponse.json( + { message: '해당 사용자를 찾을 수 없습니다. 입력 정보를 확인해 주세요.' }, + { status: 401 }, + ); + } + USER_DUMMY[userIndex].links = links; + + return HttpResponse.json(null, { status: 200 }); + }), // 유저 검색 API http.get(`${BASE_URL}/user/search`, ({ request }) => { const url = new URL(request.url); diff --git a/src/pages/setting/UserSettingPage.tsx b/src/pages/setting/UserSettingPage.tsx index 7bf329c1..449be7bb 100644 --- a/src/pages/setting/UserSettingPage.tsx +++ b/src/pages/setting/UserSettingPage.tsx @@ -113,7 +113,7 @@ export default function UserSettingPage() {
{/* 링크 */} - + diff --git a/src/pages/user/SignUpPage.tsx b/src/pages/user/SignUpPage.tsx index 637683d8..94bb0553 100644 --- a/src/pages/user/SignUpPage.tsx +++ b/src/pages/user/SignUpPage.tsx @@ -133,7 +133,7 @@ export default function SignUpPage() {
{/* 링크 */} - + {/* 인증 요청 및 확인 버튼 */}
diff --git a/src/services/userService.ts b/src/services/userService.ts index fd812f24..dff2a00b 100644 --- a/src/services/userService.ts +++ b/src/services/userService.ts @@ -1,6 +1,6 @@ import { authAxios } from '@services/axiosProvider'; import type { AxiosRequestConfig } from 'axios'; -import type { EditUserInfoRequest, EditUserInfoResponse, SearchUser } from '@/types/UserType'; +import type { EditUserInfoRequest, EditUserInfoResponse, EditUserLinksForm, SearchUser } from '@/types/UserType'; import type { TeamListWithApproval } from '@/types/TeamType'; /** @@ -16,6 +16,19 @@ export async function updateUserInfo(userInfoForm: EditUserInfoRequest, axiosCon return authAxios.patch('/user', userInfoForm, axiosConfig); } +/** + * 링크 변경 API + * + * @export + * @async + * @param {EditUserLinksForm} links - 링크 리스트 + * @param {AxiosRequestConfig} [axiosConfig={}] - axios 요청 옵션 설정 객체 + * @returns {Promise} + */ +export async function updateLinks(links: EditUserLinksForm, axiosConfig: AxiosRequestConfig = {}) { + return authAxios.patch('/user/links', links, axiosConfig); +} + /** * 유저 목록을 검색하는 API * diff --git a/src/stores/useStore.ts b/src/stores/useStore.ts index 8f203065..867fb990 100644 --- a/src/stores/useStore.ts +++ b/src/stores/useStore.ts @@ -2,7 +2,7 @@ import { create, StateCreator } from 'zustand'; import { persist, createJSONStorage } from 'zustand/middleware'; import { AUTH_SETTINGS } from '@constants/settings'; import { decrypt, encrypt } from '@utils/cryptoHelper'; -import { EditUserInfoRequest, User } from '@/types/UserType'; +import { EditUserInfoRequest, EditUserLinksForm, User } from '@/types/UserType'; // Auth Slice type AuthStore = { @@ -20,7 +20,7 @@ type AuthStore = { type UserStore = { userInfo: User; setUserInfo: (newUserInfo: User) => void; - editUserInfo: (newUserInfo: EditUserInfoRequest) => void; + editUserInfo: (newUserInfo: EditUserInfoRequest | EditUserLinksForm) => void; clearUserInfo: () => void; }; @@ -90,7 +90,7 @@ const createUserSlice: StateCreator = (set) => ({ userInfo: newUserInfo, }), - editUserInfo: (newUserInfo: EditUserInfoRequest) => + editUserInfo: (newUserInfo: EditUserInfoRequest | EditUserLinksForm) => set((state) => ({ userInfo: { ...state.userInfo, ...newUserInfo }, })), diff --git a/src/types/UserType.tsx b/src/types/UserType.tsx index 9112280e..9e375d96 100644 --- a/src/types/UserType.tsx +++ b/src/types/UserType.tsx @@ -22,6 +22,7 @@ export type UserWithRole = SearchUser & Pick; export type EditUserInfoForm = Omit; export type EditUserInfoResponse = Pick; export type EditUserInfoRequest = Omit; +export type EditUserLinksForm = Pick; export type UserSignUpForm = Omit & { verificationCode: string; diff --git a/src/utils/queryKeyGenerator.ts b/src/utils/queryKeyGenerator.ts index c35b60ee..b7bc42e9 100644 --- a/src/utils/queryKeyGenerator.ts +++ b/src/utils/queryKeyGenerator.ts @@ -4,6 +4,7 @@ import type { Project } from '@/types/ProjectType'; export const queryKeys = { userInfo: 'userInfo', + links: 'links', users: 'users', teams: 'teams', projects: 'projects', @@ -23,6 +24,17 @@ export function generateUserInfoQueryKey() { return [queryKeys.userInfo]; } +/** + * 유저 링크 queryKey 생성 함수 + * + * @export + * @param {number} userId - 유저의 고유 ID + * @returns {(string | number)[]} + */ +export function generateLinksQueryKey(userId: number) { + return [queryKeys.links, userId]; +} + /** * 유저의 팀 목록 queryKey 생성 함수 *