Skip to content
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

Feat: #192 링크 수정 기능 구현 #193

Merged
merged 7 commits into from
Oct 15, 2024
Merged
47 changes: 40 additions & 7 deletions src/components/user/auth-form/LinkContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>('');
const [links, setLinks] = useState<string[]>(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<HTMLInputElement>) => 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) {
Expand All @@ -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 (
<section>
<section className="relative">
{updateLinksIsPending && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-gray-500 bg-opacity-50">
<span className="text-white">
<Spinner />
</span>
</div>
)}
<label className="font-bold" htmlFor="link">
링크
</label>
Expand Down Expand Up @@ -81,14 +112,16 @@ 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"
/>
<button
type="button"
onClick={() => handleAddLink(link)}
className="flex size-18 items-center justify-center rounded-lg bg-sub"
aria-label="추가"
disabled={updateLinksIsPending}
>
<FaPlus className="size-8" />
</button>
Expand Down
25 changes: 22 additions & 3 deletions src/hooks/query/useUserQuery.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -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 });
Yoonyesol marked this conversation as resolved.
Show resolved Hide resolved
},
});

return mutation;
}
31 changes: 30 additions & 1 deletion src/mocks/services/userServiceHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion src/pages/setting/UserSettingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ export default function UserSettingPage() {
<hr className="my-20" />

{/* 링크 */}
<LinkContainer initialLinks={userInfoData?.links || []} />
<LinkContainer initialLinks={userInfoData?.links || []} isImmediateUpdate />
</div>
</div>
</FormProvider>
Expand Down
2 changes: 1 addition & 1 deletion src/pages/user/SignUpPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ export default function SignUpPage() {
</section>

{/* 링크 */}
<LinkContainer initialLinks={[]} />
<LinkContainer initialLinks={[]} isImmediateUpdate={false} />

{/* 인증 요청 및 확인 버튼 */}
<div className="space-y-8 text-center">
Expand Down
15 changes: 14 additions & 1 deletion src/services/userService.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand All @@ -16,6 +16,19 @@ export async function updateUserInfo(userInfoForm: EditUserInfoRequest, axiosCon
return authAxios.patch<EditUserInfoResponse>('/user', userInfoForm, axiosConfig);
}

/**
* 링크 변경 API
*
* @export
* @async
* @param {EditUserLinksForm} links - 링크 리스트
* @param {AxiosRequestConfig} [axiosConfig={}] - axios 요청 옵션 설정 객체
* @returns {Promise<AxiosResponse>}
*/
export async function updateLinks(links: EditUserLinksForm, axiosConfig: AxiosRequestConfig = {}) {
return authAxios.patch('/user/links', links, axiosConfig);
}

/**
* 유저 목록을 검색하는 API
*
Expand Down
6 changes: 3 additions & 3 deletions src/stores/useStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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;
};

Expand Down Expand Up @@ -90,7 +90,7 @@ const createUserSlice: StateCreator<Store, [], [], UserStore> = (set) => ({
userInfo: newUserInfo,
}),

editUserInfo: (newUserInfo: EditUserInfoRequest) =>
editUserInfo: (newUserInfo: EditUserInfoRequest | EditUserLinksForm) =>
set((state) => ({
userInfo: { ...state.userInfo, ...newUserInfo },
})),
Expand Down
1 change: 1 addition & 0 deletions src/types/UserType.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export type UserWithRole = SearchUser & Pick<Role, 'roleName'>;
export type EditUserInfoForm = Omit<User, 'userId' | 'provider' | 'links'>;
export type EditUserInfoResponse = Pick<User, 'userId' | 'nickname' | 'bio'>;
export type EditUserInfoRequest = Omit<EditUserInfoResponse, 'userId'>;
export type EditUserLinksForm = Pick<User, 'links'>;

export type UserSignUpForm = Omit<User, 'userId' | 'provider' | 'profileImageName'> & {
verificationCode: string;
Expand Down
12 changes: 12 additions & 0 deletions src/utils/queryKeyGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { Project } from '@/types/ProjectType';

export const queryKeys = {
userInfo: 'userInfo',
links: 'links',
users: 'users',
teams: 'teams',
projects: 'projects',
Expand All @@ -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 생성 함수
*
Expand Down