Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@ import {
} from "@tanstack/react-query";
import { useParams, useRouter } from "next/navigation";

import { teamInvitationApi } from "@/entities/team/api/invitation.api";
import type { TeamInvitationDetail } from "@/entities/team";
import {
invitationQueries,
invitationQueryKey,
} from "@/entities/team/query/invitation.queryKey";
import type { TeamInvitationDetail } from "@/entities/team/types/invitation.types";
teamInvitationApi,
} from "@/entities/team";
import Button from "@/shared/ui/Button/Button/Button";
import { Spacing } from "@/shared/ui/Spacing";

Expand Down
2 changes: 2 additions & 0 deletions src/app/taskmate/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"use client";

import AsyncBoundary from "@/shared/ui/AsyncBoundary";
import { Icon } from "@/shared/ui/Icon";
import { Spacing } from "@/shared/ui/Spacing";
Expand Down
2 changes: 1 addition & 1 deletion src/app/taskmate/team/[teamId]/management/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { useParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react";

import { inviteApi, teamDetailApi } from "@/entities/team/api/management.api";
import { inviteApi, teamDetailApi } from "@/entities/team";
import { useOverlay } from "@/shared/hooks/useOverlay";
import TextButton from "@/shared/ui/Button/TextButton/TextButton";
import DeleteModal from "@/widgets/management/DeleteModal";
Expand Down
23 changes: 23 additions & 0 deletions src/entities/team/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,30 @@
export { teamInvitationApi } from "./api/invitation.api";
export {
inviteApi,
memberApi,
memberListApi,
memberRoleApi,
teamDetailApi,
} from "./api/management.api";
export { teamApi } from "./api/team.api";
export type { CreateTeamInput } from "./model/team.model";
export { createTeamSchema } from "./model/team.model";
export {
invitationQueries,
invitationQueryKey,
} from "./query/invitation.queryKey";
export { teamQueries } from "./query/team.queryKey";
export { teamQueryOptions } from "./query/team.queryOptions";
export type { TeamInvitationDetail } from "./types/invitation.types";
export type {
InviteResponseSuccess,
MemberData,
MemberDeleteSuccessResponse,
MemberListResponseSuccess,
MemberRoleUpdateRequest,
MemberRoleUpdateSuccessResponse,
TeamDeleteResponseSuccess,
TeamResponseSuccess,
} from "./types/management.types";
export type { Member, MemberRole } from "./types/team.types";
export { TEAM_NAME_MAX_LENGTH } from "./types/team.types";
1 change: 1 addition & 0 deletions src/features/team/hooks/useCreateTeamForm/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { useCreateTeamForm } from "./useCreateTeamForm";
182 changes: 182 additions & 0 deletions src/features/team/hooks/useCreateTeamForm/useCreateTeamForm.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { act, renderHook } from "@testing-library/react";

import type { ApiError } from "@/shared/lib/api/types";

import { useCreateTeamForm } from "./useCreateTeamForm";

const mockBack = jest.fn();
const mockToast = jest.fn();
const mockMutate = jest.fn();
const mockUseCreateTeamMutation = jest.fn();

jest.mock("next/navigation", () => ({
useRouter: () => ({ back: mockBack, push: jest.fn(), replace: jest.fn() }),
}));

jest.mock("@/shared/hooks/useToast", () => ({
useToast: () => ({ toast: mockToast }),
}));

jest.mock("@/features/team/mutation/useCreateTeamMutation", () => ({
useCreateTeamMutation: (...args: unknown[]) =>
mockUseCreateTeamMutation(...args),
}));

const createSubmitEvent = (name: string) => {
const mockGet = jest.fn().mockReturnValue(name);
jest
.spyOn(global, "FormData")
.mockImplementationOnce(() => ({ get: mockGet }) as unknown as FormData);
return {
preventDefault: jest.fn(),
currentTarget: {} as HTMLFormElement,
} as unknown as React.FormEvent<HTMLFormElement>;
};

describe("useCreateTeamForm", () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseCreateTeamMutation.mockReturnValue({ mutate: mockMutate });
});

describe("폼 검증", () => {
test("빈 이름 제출 시 nameError가 설정되고 mutate가 호출되지 않는다", () => {
const { result } = renderHook(() => useCreateTeamForm());

act(() => {
result.current.handleSubmit(createSubmitEvent(""));
});

expect(result.current.nameError).toBe("팀 이름을 입력해주세요.");
expect(mockMutate).not.toHaveBeenCalled();
});

test("공백만 있는 이름 제출 시 nameError가 설정된다", () => {
const { result } = renderHook(() => useCreateTeamForm());

act(() => {
result.current.handleSubmit(createSubmitEvent(" "));
});

expect(result.current.nameError).toBe("팀 이름을 입력해주세요.");
expect(mockMutate).not.toHaveBeenCalled();
});

test("50자 초과 이름 제출 시 nameError가 설정된다", () => {
const { result } = renderHook(() => useCreateTeamForm());

act(() => {
result.current.handleSubmit(createSubmitEvent("a".repeat(51)));
});

expect(result.current.nameError).toBe(
"팀 이름은 50자 이내로 입력해주세요.",
);
expect(mockMutate).not.toHaveBeenCalled();
});

test("유효한 이름 제출 시 nameError가 초기화된다", () => {
const { result } = renderHook(() => useCreateTeamForm());

act(() => {
result.current.handleSubmit(createSubmitEvent(""));
});
expect(result.current.nameError).not.toBe("");

act(() => {
result.current.handleSubmit(createSubmitEvent("새로운 팀"));
});

expect(result.current.nameError).toBe("");
});

test("유효한 이름 제출 시 trim된 이름으로 mutate가 호출된다", () => {
const { result } = renderHook(() => useCreateTeamForm());

act(() => {
result.current.handleSubmit(createSubmitEvent(" 새로운 팀 "));
});

expect(mockMutate).toHaveBeenCalledWith("새로운 팀");
});
});

describe("성공 처리", () => {
test("onSuccess 시 성공 toast를 표시하고 이전 페이지로 이동한다", () => {
let capturedOnSuccess: (() => void) | undefined;
mockUseCreateTeamMutation.mockImplementation(
({ onSuccess }: { onSuccess: () => void }) => {
capturedOnSuccess = onSuccess;
return { mutate: mockMutate };
},
);

renderHook(() => useCreateTeamForm());

act(() => {
capturedOnSuccess?.();
});

expect(mockToast).toHaveBeenCalledWith({
variant: "success",
title: "팀 생성 완료",
description: "팀이 생성되었습니다.",
});
expect(mockBack).toHaveBeenCalledTimes(1);
});
});

describe("에러 처리", () => {
test("onError 시 서버 에러 메시지로 toast를 표시한다", () => {
let capturedOnError: ((error: ApiError) => void) | undefined;
mockUseCreateTeamMutation.mockImplementation(
({ onError }: { onError: (e: ApiError) => void }) => {
capturedOnError = onError;
return { mutate: mockMutate };
},
);

renderHook(() => useCreateTeamForm());

act(() => {
capturedOnError?.({
status: 400,
code: "VALIDATION_ERROR",
message: "이미 존재하는 팀 이름입니다.",
});
});

expect(mockToast).toHaveBeenCalledWith({
variant: "error",
title: "팀 생성 실패",
description: "이미 존재하는 팀 이름입니다.",
});
});

test("onError 시 error.message가 없으면 기본 메시지로 toast를 표시한다", () => {
let capturedOnError: ((error: ApiError) => void) | undefined;
mockUseCreateTeamMutation.mockImplementation(
({ onError }: { onError: (e: ApiError) => void }) => {
capturedOnError = onError;
return { mutate: mockMutate };
},
);

renderHook(() => useCreateTeamForm());

act(() => {
capturedOnError?.({
status: 500,
code: "INTERNAL_ERROR",
message: undefined as unknown as string,
});
});

expect(mockToast).toHaveBeenCalledWith({
variant: "error",
title: "팀 생성 실패",
description: "잠시 후 다시 시도해주세요.",
});
});
});
});
Original file line number Diff line number Diff line change
@@ -1,26 +1,21 @@
"use client";

import { useQueryClient } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { ComponentProps, useState } from "react";

import { createTeamSchema, teamQueryOptions } from "@/entities/team";
import { createTeamSchema } from "@/entities/team";
import { useCreateTeamMutation } from "@/features/team/mutation/useCreateTeamMutation";
import { useToast } from "@/shared/hooks/useToast";
import type { ApiError } from "@/shared/lib/api/types";

export const useCreateTeamForm = () => {
const router = useRouter();
const queryClient = useQueryClient();
const { toast } = useToast();

const [nameError, setNameError] = useState("");

const createMutation = useCreateTeamMutation({
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: teamQueryOptions.all().queryKey,
});
toast({
variant: "success",
title: "팀 생성 완료",
Expand Down
49 changes: 21 additions & 28 deletions src/features/team/hooks/useTeamLeaveModal.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
"use client";

import { teamApi } from "@/entities/team";
import { useLeaveTeamMutation } from "@/features/team/mutation/useLeaveTeamMutation";
import { useOverlay } from "@/shared/hooks/useOverlay";
import { useToast } from "@/shared/hooks/useToast";
import { ApiError } from "@/shared/lib/api/types";
import type { ApiError } from "@/shared/lib/api/types";
import Button from "@/shared/ui/Button/Button/Button";
import { Modal } from "@/shared/ui/Modal";

Expand Down Expand Up @@ -48,42 +48,35 @@ export const useTeamLeaveModal = (teamId: string) => {
const overlay = useOverlay();
const { toast } = useToast();

const closeLeaveTeamModal = () => {
overlay.close();
};
const leaveMutation = useLeaveTeamMutation({
onSuccess: () => {
toast({
title: "팀 나가기 성공",
description: "팀 나가기에 성공했습니다.",
variant: "success",
});
overlay.close();
},
onError: (error: ApiError) => {
toast({
title: "팀 나가기 실패",
description: error.message ?? "팀 나가기에 실패했습니다.",
variant: "error",
});
},
});

const openLeaveTeamModal = () => {
const handleConfirm = async () => {
try {
await teamApi.quitTeam(teamId);
toast({
title: "팀 나가기 성공",
description: "팀 나가기에 성공했습니다.",
variant: "success",
});
closeLeaveTeamModal();
} catch (error) {
const apiError = error as ApiError;

toast({
title: "팀 나가기 실패",
description: apiError.message ?? "팀 나가기에 실패했습니다.",
variant: "error",
});
}
};

overlay.open(
LEAVE_TEAM_MODAL_ID,
<TeamLeaveModal
onClose={closeLeaveTeamModal}
onConfirm={handleConfirm}
onClose={() => overlay.close()}
onConfirm={() => leaveMutation.mutate(teamId)}
/>,
);
};

return {
openLeaveTeamModal,
closeLeaveTeamModal,
};
};
8 changes: 8 additions & 0 deletions src/features/team/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export { useCreateTeamForm } from "./hooks/useCreateTeamForm";
export { useOptionalTeamId } from "./hooks/useOptionalTeamId";
export { useTeamId } from "./hooks/useTeamId";
export { useTeamLeaveModal } from "./hooks/useTeamLeaveModal";
export { useCreateTeamMutation } from "./mutation/useCreateTeamMutation";
export { useLeaveTeamMutation } from "./mutation/useLeaveTeamMutation";
export { formatMemberList } from "./utils/formatMemberList";
export { validateEmail } from "./utils/validateEmail";
26 changes: 0 additions & 26 deletions src/features/team/management.utils.ts

This file was deleted.

3 changes: 3 additions & 0 deletions src/features/team/mock/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { invitationsHandlers } from "./invitations";
export { managementHandler } from "./management";
export { teamsHandlers } from "./teams";
Loading
Loading