Skip to content

Commit

Permalink
Feat: #192 링크 수정 기능 구현 (#193)
Browse files Browse the repository at this point in the history
* Feat: #192 링크 변경 기능 구현

* UI: #192 링크 수정 네트워크 처리 중 로딩 추가

* Comment: #192 삭제된 주석 추가

* Refactor: #192 코드리뷰 반영 수정

* Chore: #192 코드리뷰 반영 수정

* Feat: #192 로그아웃 시 유저 링크 데이터 초기화를 위해 쿼리키에 유저 ID 등록

* Refactor: #172 이중 관리 방지를 위한 전체 링크 변수 삭제
  • Loading branch information
Yoonyesol authored Oct 15, 2024
1 parent c86cc1d commit 50276e6
Show file tree
Hide file tree
Showing 9 changed files with 124 additions and 17 deletions.
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 });
},
});

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 @@ -24,6 +24,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 @@ -24,6 +25,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

0 comments on commit 50276e6

Please sign in to comment.