From 6243f3635e48c76f1a7376c5cadbc13f712213fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A2=85=EC=A7=84=28SugarSyrup=29?= Date: Thu, 30 Apr 2026 11:22:42 +0900 Subject: [PATCH 1/7] =?UTF-8?q?refactor(#251):=20Team=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=EC=9D=98=20Summary=20=EB=A6=AC=ED=8C=A9=ED=86=A0?= =?UTF-8?q?=EB=A7=81=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/taskmate/page.tsx | 2 + src/widgets/team/MainHeroProgressCard.tsx | 262 ---------------------- src/widgets/team/Summary/Error.test.tsx | 57 +++++ src/widgets/team/Summary/Loading.tsx | 2 - src/widgets/team/Summary/Stat.tsx | 25 +++ src/widgets/team/Summary/Summary.test.tsx | 104 +++++++++ src/widgets/team/Summary/Summary.tsx | 190 +++++++++++++++- 7 files changed, 366 insertions(+), 276 deletions(-) delete mode 100644 src/widgets/team/MainHeroProgressCard.tsx create mode 100644 src/widgets/team/Summary/Error.test.tsx create mode 100644 src/widgets/team/Summary/Stat.tsx create mode 100644 src/widgets/team/Summary/Summary.test.tsx diff --git a/src/app/taskmate/page.tsx b/src/app/taskmate/page.tsx index dbd54ef2..68aa9572 100644 --- a/src/app/taskmate/page.tsx +++ b/src/app/taskmate/page.tsx @@ -1,3 +1,5 @@ +"use client"; + import AsyncBoundary from "@/shared/ui/AsyncBoundary"; import { Icon } from "@/shared/ui/Icon"; import { Spacing } from "@/shared/ui/Spacing"; diff --git a/src/widgets/team/MainHeroProgressCard.tsx b/src/widgets/team/MainHeroProgressCard.tsx deleted file mode 100644 index f0078619..00000000 --- a/src/widgets/team/MainHeroProgressCard.tsx +++ /dev/null @@ -1,262 +0,0 @@ -import Image, { StaticImageData } from "next/image"; -import { useRouter } from "next/navigation"; - -import { useTeamId } from "@/features/team/hooks/useTeamId"; -import FireIcon from "@/shared/assets/images/fire.png"; -import SettingIcon from "@/shared/assets/images/setting.png"; -import { cn } from "@/shared/utils/styles/cn"; - -import { ProgressBar } from "../../shared/ui/ProgressBar"; - -type HeroColor = "blue" | "green"; - -interface MainHeroProgressCardProps { - title: string; - progress: number; - todoCount: number; - completedCount: number; - overdueTodoCount: number; - color?: HeroColor; - className?: string; - statusLabel?: string; - statusIconSrc?: StaticImageData | string; - isAdmin: boolean; -} - -const CARD_BG: Record = { - blue: "bg-[var(--color-blue-800)]", - green: "bg-[var(--color-green-800)]", -}; - -function StatItem({ - label, - value, - suffix, -}: { - label: string; - value: number; - suffix: string; -}) { - return ( -
-

- {label} -

-

- - {value} - - - {suffix} - -

-
- ); -} - -const Character = () => { - return ( - - - - - - - - - - - - - - - - - - - - - - - ); -}; - -export const MainHeroProgressCard = ({ - title, - todoCount, - progress, - completedCount, - overdueTodoCount, - isAdmin, - color = "green", - className, - statusLabel = "거의 다 왔어요", - statusIconSrc = FireIcon, -}: MainHeroProgressCardProps) => { - const router = useRouter(); - const teamId = useTeamId(); - - return ( -
-
-

- {title} -

- - {isAdmin && ( - - )} -
- -
- - - - -
- -
- {/* 말풍선 */} - {progress >= 80 && progress < 100 && ( -
-
- - - {statusLabel} - - -
-
- )} - - -
- - -
- ); -}; diff --git a/src/widgets/team/Summary/Error.test.tsx b/src/widgets/team/Summary/Error.test.tsx new file mode 100644 index 00000000..6e8b9fca --- /dev/null +++ b/src/widgets/team/Summary/Error.test.tsx @@ -0,0 +1,57 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import SummaryError from "./Error"; + +describe("SummaryError", () => { + test("고정 안내 문구를 렌더링한다", () => { + render( + , + ); + + expect( + screen.getByText("팀 요약 정보를 불러오지 못했어요"), + ).toBeInTheDocument(); + }); + + test("error.message가 있을 때 해당 메시지를 표시한다", () => { + render( + , + ); + + expect( + screen.getByText("네트워크 오류가 발생했습니다"), + ).toBeInTheDocument(); + }); + + test("error.message가 없을 때 폴백 메시지를 표시한다", () => { + render( + , + ); + + expect(screen.getByText("잠시 후 다시 시도해주세요.")).toBeInTheDocument(); + }); + + test("다시 시도 버튼 클릭 시 onReset이 호출된다", async () => { + const onReset = jest.fn(); + render( + , + ); + + await userEvent.click(screen.getByRole("button", { name: "다시 시도" })); + + expect(onReset).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/widgets/team/Summary/Loading.tsx b/src/widgets/team/Summary/Loading.tsx index 5defdeb1..594fade4 100644 --- a/src/widgets/team/Summary/Loading.tsx +++ b/src/widgets/team/Summary/Loading.tsx @@ -1,5 +1,3 @@ -"use client"; - import Spinner from "@/shared/ui/Spinner"; export default function SummaryLoading() { diff --git a/src/widgets/team/Summary/Stat.tsx b/src/widgets/team/Summary/Stat.tsx new file mode 100644 index 00000000..52901166 --- /dev/null +++ b/src/widgets/team/Summary/Stat.tsx @@ -0,0 +1,25 @@ +export function Stat({ + label, + value, + suffix, +}: { + label: string; + value: number; + suffix: string; +}) { + return ( +
+

+ {label} +

+

+ + {value} + + + {suffix} + +

+
+ ); +} diff --git a/src/widgets/team/Summary/Summary.test.tsx b/src/widgets/team/Summary/Summary.test.tsx new file mode 100644 index 00000000..781c67e6 --- /dev/null +++ b/src/widgets/team/Summary/Summary.test.tsx @@ -0,0 +1,104 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +import { useParams, useRouter } from "next/navigation"; +import { Suspense } from "react"; + +import Summary from "./Summary"; + +jest.mock("next/navigation", () => ({ + useParams: jest.fn(), + useRouter: jest.fn(), +})); + +jest.mock("next/image", () => ({ + __esModule: true, + default: ({ + alt, + src, + ...props + }: { + alt: string; + src: string; + [key: string]: unknown; + }) => ( + // eslint-disable-next-line @next/next/no-img-element + {alt} + ), +})); + +const mockUseParams = useParams as jest.MockedFunction; +const mockUseRouter = useRouter as jest.MockedFunction; + +const mockSummary = { + teamId: 1, + teamName: "프론트엔드 1팀", + isAdmin: true, + todayProgressPercent: 70, + todayTodoCount: 10, + overdueTodoCount: 2, + doneTodoCount: 8, +}; + +const makeQueryClient = () => + new QueryClient({ defaultOptions: { queries: { retry: false } } }); + +const createWrapper = (queryClient: QueryClient) => { + function Wrapper({ children }: { children: React.ReactNode }) { + return ( + + loading}>{children} + + ); + } + return Wrapper; +}; + +describe("Summary", () => { + beforeEach(() => { + mockUseParams.mockReturnValue({ teamId: "1" } as ReturnType< + typeof useParams + >); + mockUseRouter.mockReturnValue({ push: jest.fn() } as unknown as ReturnType< + typeof useRouter + >); + }); + + const renderSummary = (data = mockSummary) => { + const queryClient = makeQueryClient(); + queryClient.setQueryData(["team", "1", "summary"], data); + return render(, { wrapper: createWrapper(queryClient) }); + }; + + test("팀 이름을 렌더링한다", () => { + renderSummary(); + + expect(screen.getByText("프론트엔드 1팀")).toBeInTheDocument(); + }); + + test("오늘의 진행률, 할 일 수, 밀린 할 일, 완료한 일을 표시한다", () => { + renderSummary(); + + expect(screen.getByText("70")).toBeInTheDocument(); + expect(screen.getByText("10")).toBeInTheDocument(); + expect(screen.getByText("2")).toBeInTheDocument(); + expect(screen.getByText("8")).toBeInTheDocument(); + }); + + describe("관리자 여부", () => { + test("isAdmin이 true일 때 설정 버튼이 표시된다", () => { + renderSummary({ ...mockSummary, isAdmin: true }); + + expect(screen.getByAltText("설정")).toBeInTheDocument(); + }); + + test("isAdmin이 false일 때 설정 버튼이 표시되지 않는다", () => { + renderSummary({ ...mockSummary, isAdmin: false }); + + expect(screen.queryByAltText("설정")).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/widgets/team/Summary/Summary.tsx b/src/widgets/team/Summary/Summary.tsx index 6e108146..7f1e08b1 100644 --- a/src/widgets/team/Summary/Summary.tsx +++ b/src/widgets/team/Summary/Summary.tsx @@ -1,11 +1,125 @@ +"use client"; + import { useSuspenseQuery } from "@tanstack/react-query"; +import Image from "next/image"; +import { useRouter } from "next/navigation"; -import { teamQueries } from "@/entities/team/query/team.queryKey"; +import { teamQueryOptions } from "@/entities/team"; import { useTeamId } from "@/features/team/hooks/useTeamId"; -import { MainHeroProgressCard } from "@/widgets/team/MainHeroProgressCard"; +import SettingIcon from "@/shared/assets/images/setting.png"; +import { ProgressBar } from "@/shared/ui/ProgressBar"; +import { cn } from "@/shared/utils/styles/cn"; + +import { Stat } from "./Stat"; + +const Character = () => { + return ( + + + + + + + + + + + + + + + + + + + + + + + ); +}; export default function Summary() { const teamId = useTeamId(); + const router = useRouter(); + const { data: { teamName, @@ -15,17 +129,69 @@ export default function Summary() { isAdmin, doneTodoCount, }, - } = useSuspenseQuery(teamQueries.summary(teamId)); + } = useSuspenseQuery(teamQueryOptions.summary(teamId)); return ( - +
+
+

+ {teamName} +

+ + {isAdmin && ( + + )} +
+ +
+ + + + +
+ +
+ +
+ + +
); } From ee9782289c34e66448c1eb5858322cbc2b82da1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A2=85=EC=A7=84=28SugarSyrup=29?= Date: Thu, 30 Apr 2026 12:46:11 +0900 Subject: [PATCH 2/7] refacor(#251): refactor & write tests widgets/team/GoalList --- src/widgets/team/GoalList/Error.test.tsx | 59 +++++++++++++ src/widgets/team/GoalList/GoalList.test.tsx | 98 +++++++++++++++++++++ src/widgets/team/GoalList/GoalList.tsx | 7 +- src/widgets/team/GoalList/Loading.tsx | 2 - 4 files changed, 161 insertions(+), 5 deletions(-) create mode 100644 src/widgets/team/GoalList/Error.test.tsx create mode 100644 src/widgets/team/GoalList/GoalList.test.tsx diff --git a/src/widgets/team/GoalList/Error.test.tsx b/src/widgets/team/GoalList/Error.test.tsx new file mode 100644 index 00000000..241606bc --- /dev/null +++ b/src/widgets/team/GoalList/Error.test.tsx @@ -0,0 +1,59 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import GoalListError from "./Error"; + +jest.mock("@/shared/ui/Icon", () => ({ + Icon: ({ name }: { name: string }) => , +})); + +describe("GoalListError", () => { + test("고정 안내 문구를 렌더링한다", () => { + render( + , + ); + + expect(screen.getByText("목표를 불러오지 못했어요")).toBeInTheDocument(); + }); + + test("error.message가 있을 때 해당 메시지를 표시한다", () => { + render( + , + ); + + expect( + screen.getByText("네트워크 오류가 발생했습니다"), + ).toBeInTheDocument(); + }); + + test("error.message가 없을 때 폴백 메시지를 표시한다", () => { + render( + , + ); + + expect(screen.getByText("잠시 후 다시 시도해주세요.")).toBeInTheDocument(); + }); + + test("다시 시도 버튼 클릭 시 onReset이 호출된다", async () => { + const onReset = jest.fn(); + render( + , + ); + + await userEvent.click(screen.getByRole("button", { name: "다시 시도" })); + + expect(onReset).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/widgets/team/GoalList/GoalList.test.tsx b/src/widgets/team/GoalList/GoalList.test.tsx new file mode 100644 index 00000000..1b6f5aad --- /dev/null +++ b/src/widgets/team/GoalList/GoalList.test.tsx @@ -0,0 +1,98 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useParams } from "next/navigation"; + +import { useInfiniteScroll } from "@/shared/hooks/useInfiniteScroll"; + +import GoalList from "./GoalList"; + +jest.mock("next/navigation", () => ({ + useParams: jest.fn(), +})); + +jest.mock("@/shared/hooks/useInfiniteScroll", () => ({ + useInfiniteScroll: jest.fn(), +})); + +jest.mock("@/widgets/team/MainSecondaryProgressCard", () => ({ + MainSecondaryProgressCard: ({ title }: { title: string }) => ( +
{title}
+ ), +})); + +jest.mock("@/shared/ui/Icon", () => ({ + Icon: ({ name }: { name: string }) => , +})); + +const mockUseParams = useParams as jest.MockedFunction; +const mockUseInfiniteScroll = useInfiniteScroll as jest.Mock; + +const mockData = { + pages: [ + { + items: [ + { goalId: 1, name: "목표 A", progressPercent: 50, isFavorite: false }, + { goalId: 2, name: "목표 B", progressPercent: 80, isFavorite: true }, + ], + nextCursor: null, + size: 2, + }, + ], + pageParams: [null], +}; + +describe("GoalList", () => { + beforeEach(() => { + mockUseParams.mockReturnValue({ teamId: "1" } as ReturnType< + typeof useParams + >); + mockUseInfiniteScroll.mockReturnValue({ + ref: { current: null }, + data: mockData, + isFetchingNextPage: false, + }); + }); + + test("목표 목록을 렌더링한다", () => { + render(); + + expect(screen.getByText("목표 A")).toBeInTheDocument(); + expect(screen.getByText("목표 B")).toBeInTheDocument(); + expect(screen.getAllByTestId("goal-card")).toHaveLength(2); + }); + + test("총 목표 수를 표시한다", () => { + render(); + + expect(screen.getByText("2개")).toBeInTheDocument(); + }); + + describe("정렬", () => { + test("기본 정렬이 최신순으로 표시된다", () => { + render(); + + expect(screen.getByText("최신순")).toBeInTheDocument(); + }); + + test("정렬 버튼 클릭 시 정렬 옵션 목록이 열린다", async () => { + render(); + + await userEvent.click(screen.getByText("최신순")); + + expect(screen.getByRole("listbox")).toBeInTheDocument(); + }); + + test("오래된순 선택 시 정렬이 변경되고 쿼리를 다시 요청한다", async () => { + render(); + + await userEvent.click(screen.getByText("최신순")); + await userEvent.click(screen.getByText("오래된순")); + + expect(mockUseInfiniteScroll).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: ["team", "1", "goals", "infinite", "OLDEST"], + }), + ); + }); + }); +}); diff --git a/src/widgets/team/GoalList/GoalList.tsx b/src/widgets/team/GoalList/GoalList.tsx index 45394020..ccf5e20f 100644 --- a/src/widgets/team/GoalList/GoalList.tsx +++ b/src/widgets/team/GoalList/GoalList.tsx @@ -4,11 +4,13 @@ import { useMemo, useState } from "react"; import { goalQueryOptions, SortType } from "@/entities/goal"; import { useTeamId } from "@/features/team/hooks/useTeamId"; -import { useInfiniteScroll } from "@/shared/hooks/useInfiniteScroll/useInfiniteScroll"; +import { useInfiniteScroll } from "@/shared/hooks/useInfiniteScroll"; import { Icon } from "@/shared/ui/Icon"; import { Order } from "@/shared/ui/Order"; import { MainSecondaryProgressCard } from "@/widgets/team/MainSecondaryProgressCard"; +const sortOptions = ["최신순", "오래된순"]; + const sortTypeByLabel: Record = { 최신순: "LATEST", 오래된순: "OLDEST", @@ -16,7 +18,6 @@ const sortTypeByLabel: Record = { export default function GoalList() { const teamId = useTeamId(); - const sortOptions = ["최신순", "오래된순"]; const [selectedSort, setSelectedSort] = useState("최신순"); const sort = sortTypeByLabel[selectedSort] ?? "LATEST"; @@ -53,7 +54,7 @@ export default function GoalList() { /> -
+
{goalList.map((goal) => ( Date: Thu, 30 Apr 2026 16:32:02 +0900 Subject: [PATCH 3/7] refacor(#251): refactor & write tests shared/ui/Order --- src/shared/ui/Order/Order.stories.tsx | 17 +++-- src/shared/ui/Order/Order.test.tsx | 89 +++++++++++++++++++++++++++ src/shared/ui/Order/Order.tsx | 11 ++-- 3 files changed, 109 insertions(+), 8 deletions(-) diff --git a/src/shared/ui/Order/Order.stories.tsx b/src/shared/ui/Order/Order.stories.tsx index 10eaac9d..497795a9 100644 --- a/src/shared/ui/Order/Order.stories.tsx +++ b/src/shared/ui/Order/Order.stories.tsx @@ -1,4 +1,4 @@ -import type { StoryObj } from "@storybook/nextjs-vite"; +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; import { Order } from "./Order"; @@ -6,17 +6,26 @@ const meta = { title: "shared/ui/Order", component: Order, tags: ["autodocs"], + decorators: [ + (Story) => ( +
+ 목록 제목 + +
+ ), + ], args: { options: ["마감일 순", "최신순", "오래된순"], selected: "마감일 순", onSelect: () => {}, }, -}; +} satisfies Meta; export default meta; +type Story = StoryObj; -export const Closed: StoryObj = {}; +export const Closed: Story = {}; -export const AnotherSelected: StoryObj = { +export const AnotherSelected: Story = { args: { selected: "최신순" }, }; diff --git a/src/shared/ui/Order/Order.test.tsx b/src/shared/ui/Order/Order.test.tsx index eacc0d79..6a7b81ed 100644 --- a/src/shared/ui/Order/Order.test.tsx +++ b/src/shared/ui/Order/Order.test.tsx @@ -110,4 +110,93 @@ describe("Order", () => { expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); }); }); + + describe("ARIA 속성", () => { + test("초기에 버튼의 aria-expanded가 false다", () => { + render( + , + ); + expect(screen.getByRole("button")).toHaveAttribute( + "aria-expanded", + "false", + ); + }); + + test("드롭다운이 열리면 버튼의 aria-expanded가 true가 된다", async () => { + render( + , + ); + await userEvent.click(screen.getByRole("button")); + expect(screen.getByRole("button")).toHaveAttribute( + "aria-expanded", + "true", + ); + }); + + test("선택된 옵션의 aria-selected가 true이고 나머지는 false다", async () => { + render( + , + ); + await userEvent.click(screen.getByRole("button")); + expect(screen.getByRole("option", { name: "마감일 순" })).toHaveAttribute( + "aria-selected", + "true", + ); + expect(screen.getByRole("option", { name: "최신순" })).toHaveAttribute( + "aria-selected", + "false", + ); + }); + }); + + describe("외부 클릭", () => { + test("드롭다운 외부 클릭 시 닫힌다", async () => { + render( + , + ); + await userEvent.click(screen.getByRole("button")); + expect(screen.getByRole("listbox")).toBeInTheDocument(); + + await userEvent.click(document.body); + expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); + }); + }); + + describe("selected prop 변경", () => { + test("selected prop이 변경되면 표시값이 업데이트된다", () => { + const { rerender } = render( + , + ); + expect(screen.getByText("마감일 순")).toBeInTheDocument(); + + rerender( + , + ); + expect(screen.getByText("최신순")).toBeInTheDocument(); + }); + }); }); diff --git a/src/shared/ui/Order/Order.tsx b/src/shared/ui/Order/Order.tsx index 15292a1f..05edfe44 100644 --- a/src/shared/ui/Order/Order.tsx +++ b/src/shared/ui/Order/Order.tsx @@ -12,9 +12,9 @@ interface OrderProps { export const Order = ({ options, selected, onSelect }: OrderProps) => { const { isOpen, - selected: selectedSort, + selected: current, toggle, - selectItem, + close, containerRef, } = useDropdown(options, selected); @@ -27,9 +27,10 @@ export const Order = ({ options, selected, onSelect }: OrderProps) => { type="button" className="flex shrink-0 cursor-pointer items-center gap-1" onClick={toggle} + aria-expanded={isOpen} > - {selectedSort} + {current} { {options.map((option) => (
  • { - selectItem(option); + close(); onSelect(option); }} > From ba552c3272a69dee7a8c087f609451e276626680 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A2=85=EC=A7=84=28SugarSyrup=29?= Date: Thu, 30 Apr 2026 18:00:17 +0900 Subject: [PATCH 4/7] =?UTF-8?q?refactor(#251):=20MainSecondaryProgressCard?= =?UTF-8?q?=20=EB=B6=88=EB=AA=85=ED=99=95=ED=95=9C=20=EC=9D=B4=EB=A6=84=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EB=B0=8F=20test=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/widgets/home/FavoriteGoalsItem.tsx | 4 +- src/widgets/team/GoalList/GoalList.test.tsx | 4 +- src/widgets/team/GoalList/GoalList.tsx | 4 +- src/widgets/team/GoalProgressCard.stories.tsx | 52 +++++++ src/widgets/team/GoalProgressCard.test.tsx | 136 ++++++++++++++++++ ...yProgressCard.tsx => GoalProgressCard.tsx} | 17 ++- 6 files changed, 202 insertions(+), 15 deletions(-) create mode 100644 src/widgets/team/GoalProgressCard.stories.tsx create mode 100644 src/widgets/team/GoalProgressCard.test.tsx rename src/widgets/team/{MainSecondaryProgressCard.tsx => GoalProgressCard.tsx} (82%) diff --git a/src/widgets/home/FavoriteGoalsItem.tsx b/src/widgets/home/FavoriteGoalsItem.tsx index 3bc36d0f..d6b3775b 100644 --- a/src/widgets/home/FavoriteGoalsItem.tsx +++ b/src/widgets/home/FavoriteGoalsItem.tsx @@ -1,6 +1,6 @@ "use client"; -import { MainSecondaryProgressCard } from "@/widgets/team/MainSecondaryProgressCard"; +import { GoalProgressCard } from "@/widgets/team/GoalProgressCard"; export interface FavoriteGoalsItemProps { teamId: number; @@ -23,7 +23,7 @@ export function FavoriteGoalsItem({ {teamName} - ({ useInfiniteScroll: jest.fn(), })); -jest.mock("@/widgets/team/MainSecondaryProgressCard", () => ({ - MainSecondaryProgressCard: ({ title }: { title: string }) => ( +jest.mock("@/widgets/team/GoalProgressCard", () => ({ + GoalProgressCard: ({ title }: { title: string }) => (
    {title}
    ), })); diff --git a/src/widgets/team/GoalList/GoalList.tsx b/src/widgets/team/GoalList/GoalList.tsx index ccf5e20f..014503f3 100644 --- a/src/widgets/team/GoalList/GoalList.tsx +++ b/src/widgets/team/GoalList/GoalList.tsx @@ -7,7 +7,7 @@ import { useTeamId } from "@/features/team/hooks/useTeamId"; import { useInfiniteScroll } from "@/shared/hooks/useInfiniteScroll"; import { Icon } from "@/shared/ui/Icon"; import { Order } from "@/shared/ui/Order"; -import { MainSecondaryProgressCard } from "@/widgets/team/MainSecondaryProgressCard"; +import { GoalProgressCard } from "@/widgets/team/GoalProgressCard"; const sortOptions = ["최신순", "오래된순"]; @@ -57,7 +57,7 @@ export default function GoalList() {
    {goalList.map((goal) => ( - ; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Favorite: Story = { + args: { isFavorite: true }, +}; + +export const BlueColor: Story = { + args: { color: "blue", progress: 30 }, +}; + +export const FullProgress: Story = { + args: { progress: 100 }, +}; + +export const LongTitle: Story = { + args: { + title: "매우 긴 목표 제목이 있을 때 말줄임표로 처리되어야 하는 경우입니다", + }, +}; + +export const WithIcon: Story = { + args: { + iconSrc: + "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a7/Camponotus_flavomarginatus_ant.jpg/24px-Camponotus_flavomarginatus_ant.jpg", + }, +}; diff --git a/src/widgets/team/GoalProgressCard.test.tsx b/src/widgets/team/GoalProgressCard.test.tsx new file mode 100644 index 00000000..b2311542 --- /dev/null +++ b/src/widgets/team/GoalProgressCard.test.tsx @@ -0,0 +1,136 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useRouter } from "next/navigation"; + +import { useToggleGoalFavoriteMutation } from "@/features/goal/mutation/useToggleGoalFavoriteMutation"; + +import { GoalProgressCard } from "./GoalProgressCard"; + +jest.mock("next/navigation", () => ({ + useRouter: jest.fn(), +})); + +jest.mock("next/image", () => ({ + __esModule: true, + default: ({ alt }: { alt: string }) => ( + {alt} + ), +})); + +jest.mock("@/features/goal/mutation/useToggleGoalFavoriteMutation", () => ({ + useToggleGoalFavoriteMutation: jest.fn(), +})); + +jest.mock("@/shared/ui/ProgressBar", () => ({ + ProgressBar: () =>
    , +})); + +jest.mock("@/widgets/common/StarToggleButton", () => ({ + StarToggleButton: ({ onToggle }: { onToggle: () => void }) => ( + + ), +})); + +const mockPush = jest.fn(); +const mockMutate = jest.fn(); + +const mockUseRouter = useRouter as jest.MockedFunction; +const mockUseToggleGoalFavoriteMutation = + useToggleGoalFavoriteMutation as jest.MockedFunction< + typeof useToggleGoalFavoriteMutation + >; + +const defaultProps = { + teamId: "1", + goalId: 42, + title: "테스트 목표", + progress: 75, + isFavorite: false, +}; + +describe("GoalProgressCard", () => { + beforeEach(() => { + mockUseRouter.mockReturnValue({ + push: mockPush, + } as unknown as ReturnType); + mockUseToggleGoalFavoriteMutation.mockReturnValue({ + mutate: mockMutate, + } as unknown as ReturnType); + mockPush.mockClear(); + mockMutate.mockClear(); + }); + + describe("기본 렌더링", () => { + test("제목이 표시된다", () => { + render(); + expect(screen.getByText("테스트 목표")).toBeInTheDocument(); + }); + + test("진행률 바가 렌더링된다", () => { + render(); + expect(screen.getByTestId("progress-bar")).toBeInTheDocument(); + }); + + test("진행률 퍼센트가 표시된다", () => { + render( + , + ); + expect(screen.getByText("75%")).toBeInTheDocument(); + }); + }); + + describe("카드 클릭", () => { + test("클릭 시 팀 목표 상세 페이지로 이동한다", async () => { + render(); + await userEvent.click(screen.getByText("테스트 목표")); + expect(mockPush).toHaveBeenCalledWith("/taskmate/team/1/goal/42"); + }); + }); + + describe("iconSrc가 없을 때", () => { + test("즐겨찾기 버튼이 렌더링된다", () => { + render(); + expect(screen.getByTestId("star-toggle")).toBeInTheDocument(); + }); + + test("즐겨찾기 버튼 클릭 시 toggleFavorite이 goalId로 호출된다", async () => { + render(); + await userEvent.click(screen.getByTestId("star-toggle")); + expect(mockMutate).toHaveBeenCalledWith(42); + expect(mockMutate).toHaveBeenCalledTimes(1); + }); + }); + + describe("iconSrc가 있을 때", () => { + test("이미지가 렌더링된다", () => { + render( + , + ); + expect(screen.getByTestId("goal-icon")).toBeInTheDocument(); + }); + + test("즐겨찾기 버튼이 렌더링되지 않는다", () => { + render( + , + ); + expect(screen.queryByTestId("star-toggle")).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/widgets/team/MainSecondaryProgressCard.tsx b/src/widgets/team/GoalProgressCard.tsx similarity index 82% rename from src/widgets/team/MainSecondaryProgressCard.tsx rename to src/widgets/team/GoalProgressCard.tsx index 0302ede0..9ea2c719 100644 --- a/src/widgets/team/MainSecondaryProgressCard.tsx +++ b/src/widgets/team/GoalProgressCard.tsx @@ -4,14 +4,13 @@ import Image, { StaticImageData } from "next/image"; import { useRouter } from "next/navigation"; import { useToggleGoalFavoriteMutation } from "@/features/goal/mutation/useToggleGoalFavoriteMutation"; +import { ProgressBar } from "@/shared/ui/ProgressBar"; import { cn } from "@/shared/utils/styles/cn"; - -import { ProgressBar } from "../../shared/ui/ProgressBar"; -import { StarToggleButton } from "../common/StarToggleButton"; +import { StarToggleButton } from "@/widgets/common/StarToggleButton"; type SecondaryColor = "blue" | "green"; -interface MainSecondaryProgressCardProps { +interface GoalProgressCardProps { teamId: string; goalId: number; title: string; @@ -23,11 +22,11 @@ interface MainSecondaryProgressCardProps { } const THEME = { - blue: { text: "text-[var(--color-blue-800)]" }, - green: { text: "text-[var(--color-green-800)]" }, + blue: { text: "text-blue-800" }, + green: { text: "text-green-800" }, } as const; -export const MainSecondaryProgressCard = ({ +export const GoalProgressCard = ({ teamId, title, progress, @@ -36,7 +35,7 @@ export const MainSecondaryProgressCard = ({ isFavorite, iconSrc, goalId, -}: MainSecondaryProgressCardProps) => { +}: GoalProgressCardProps) => { const router = useRouter(); const { mutate: toggleFavorite } = useToggleGoalFavoriteMutation(); const theme = THEME[color]; @@ -52,7 +51,7 @@ export const MainSecondaryProgressCard = ({ }} >
    -

    +

    {title}

    {iconSrc ? ( From 43d3fb932bc0e04e25f2a0ded64f23dd197dc753 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A2=85=EC=A7=84=28SugarSyrup=29?= Date: Thu, 30 Apr 2026 20:23:24 +0900 Subject: [PATCH 5/7] =?UTF-8?q?refactor(#251):=20MemberList=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=86=A0=EB=A7=81=20=EB=B0=8F=20test=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/widgets/home/FavoriteGoalsItem.tsx | 2 +- src/widgets/team/GoalList/GoalList.tsx | 2 +- .../GoalProgressCard.stories.tsx | 0 .../GoalProgressCard.test.tsx | 0 .../GoalProgressCard.tsx | 0 .../team/MemberList/Member.stories.tsx | 35 +++++ src/widgets/team/MemberList/Member.tsx | 18 +-- .../team/MemberList/MemberList.test.tsx | 144 ++++++++++++++++++ src/widgets/team/MemberList/MemberList.tsx | 12 +- 9 files changed, 191 insertions(+), 22 deletions(-) rename src/widgets/team/{ => GoalProgressCard}/GoalProgressCard.stories.tsx (100%) rename src/widgets/team/{ => GoalProgressCard}/GoalProgressCard.test.tsx (100%) rename src/widgets/team/{ => GoalProgressCard}/GoalProgressCard.tsx (100%) create mode 100644 src/widgets/team/MemberList/Member.stories.tsx create mode 100644 src/widgets/team/MemberList/MemberList.test.tsx diff --git a/src/widgets/home/FavoriteGoalsItem.tsx b/src/widgets/home/FavoriteGoalsItem.tsx index d6b3775b..2b191e04 100644 --- a/src/widgets/home/FavoriteGoalsItem.tsx +++ b/src/widgets/home/FavoriteGoalsItem.tsx @@ -1,6 +1,6 @@ "use client"; -import { GoalProgressCard } from "@/widgets/team/GoalProgressCard"; +import { GoalProgressCard } from "@/widgets/team/GoalProgressCard/GoalProgressCard"; export interface FavoriteGoalsItemProps { teamId: number; diff --git a/src/widgets/team/GoalList/GoalList.tsx b/src/widgets/team/GoalList/GoalList.tsx index 014503f3..f5352c10 100644 --- a/src/widgets/team/GoalList/GoalList.tsx +++ b/src/widgets/team/GoalList/GoalList.tsx @@ -7,7 +7,7 @@ import { useTeamId } from "@/features/team/hooks/useTeamId"; import { useInfiniteScroll } from "@/shared/hooks/useInfiniteScroll"; import { Icon } from "@/shared/ui/Icon"; import { Order } from "@/shared/ui/Order"; -import { GoalProgressCard } from "@/widgets/team/GoalProgressCard"; +import { GoalProgressCard } from "@/widgets/team/GoalProgressCard/GoalProgressCard"; const sortOptions = ["최신순", "오래된순"]; diff --git a/src/widgets/team/GoalProgressCard.stories.tsx b/src/widgets/team/GoalProgressCard/GoalProgressCard.stories.tsx similarity index 100% rename from src/widgets/team/GoalProgressCard.stories.tsx rename to src/widgets/team/GoalProgressCard/GoalProgressCard.stories.tsx diff --git a/src/widgets/team/GoalProgressCard.test.tsx b/src/widgets/team/GoalProgressCard/GoalProgressCard.test.tsx similarity index 100% rename from src/widgets/team/GoalProgressCard.test.tsx rename to src/widgets/team/GoalProgressCard/GoalProgressCard.test.tsx diff --git a/src/widgets/team/GoalProgressCard.tsx b/src/widgets/team/GoalProgressCard/GoalProgressCard.tsx similarity index 100% rename from src/widgets/team/GoalProgressCard.tsx rename to src/widgets/team/GoalProgressCard/GoalProgressCard.tsx diff --git a/src/widgets/team/MemberList/Member.stories.tsx b/src/widgets/team/MemberList/Member.stories.tsx new file mode 100644 index 00000000..7f34e7de --- /dev/null +++ b/src/widgets/team/MemberList/Member.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; + +import Member from "./Member"; + +const meta = { + title: "widgets/team/Member", + component: Member, + tags: ["autodocs"], + args: { + avatar: "", + nickName: "홍길동", + email: "hong@example.com", + isMe: false, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const IsMe: Story = { + args: { isMe: true }, +}; + +export const NoEmail: Story = { + args: { email: "" }, +}; + +export const LongNickname: Story = { + args: { + nickName: "매우긴닉네임이너무나길어서말줄임표처리되는경우", + isMe: true, + }, +}; diff --git a/src/widgets/team/MemberList/Member.tsx b/src/widgets/team/MemberList/Member.tsx index 04aae066..92c70a2a 100644 --- a/src/widgets/team/MemberList/Member.tsx +++ b/src/widgets/team/MemberList/Member.tsx @@ -1,15 +1,12 @@ import Image from "next/image"; import defaultAvatar from "@/shared/assets/images/avatar.png"; -// @TODO: Icon Convention 위반 -// import Crown from "@/components/common/Icons/Crown"; import { cn } from "@/shared/utils/styles/cn"; export type MemberProps = { avatar: string; nickName: string; email: string; - isAdmin?: boolean; isMe?: boolean; className?: string; }; @@ -18,7 +15,6 @@ export default function Member({ avatar, nickName, email, - isAdmin = false, isMe = false, className, }: MemberProps) { @@ -27,7 +23,7 @@ export default function Member({ return (
    @@ -39,25 +35,17 @@ export default function Member({ height={40} className="rounded-full object-cover" /> - {/* {isAdmin && ( - - - - )} */}
    {nickName} {isMe ? ( - + ) : null} diff --git a/src/widgets/team/MemberList/MemberList.test.tsx b/src/widgets/team/MemberList/MemberList.test.tsx new file mode 100644 index 00000000..332146d3 --- /dev/null +++ b/src/widgets/team/MemberList/MemberList.test.tsx @@ -0,0 +1,144 @@ +import { useSuspenseQuery } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import { useTeamId } from "@/features/team/hooks/useTeamId"; +import { useTeamLeaveModal } from "@/features/team/hooks/useTeamLeaveModal"; + +import MemberListComponent from "./MemberList"; + +jest.mock("@tanstack/react-query", () => ({ + ...jest.requireActual("@tanstack/react-query"), + useSuspenseQuery: jest.fn(), +})); + +jest.mock("@/features/team/hooks/useTeamId", () => ({ + useTeamId: jest.fn(), +})); + +jest.mock("@/features/team/hooks/useTeamLeaveModal", () => ({ + useTeamLeaveModal: jest.fn(), +})); + +jest.mock("@/features/team/utils/formatMemberList", () => ({ + formatMemberList: jest.fn((members: T[]) => members), +})); + +jest.mock("@/shared/ui/Icon", () => ({ + Icon: ({ name }: { name: string }) => , +})); + +jest.mock("@/shared/ui/Button/TextButton/TextButton", () => ({ + __esModule: true, + default: ({ + children, + onClick, + leftIcon, + }: { + children: React.ReactNode; + onClick?: () => void; + leftIcon?: React.ReactNode; + }) => ( + + ), +})); + +jest.mock("./Member", () => ({ + __esModule: true, + default: ({ nickName }: { nickName: string }) => ( +
    {nickName}
    + ), +})); + +const mockOpenLeaveTeamModal = jest.fn(); + +const mockMembers = [ + { + id: 1, + userId: 100, + userNickname: "홍길동", + userEmail: "hong@example.com", + profileImageUrl: null, + role: "MEMBER", + }, + { + id: 2, + userId: 200, + userNickname: "김철수", + userEmail: "kim@example.com", + profileImageUrl: null, + role: "ADMIN", + }, +]; + +const mockUseTeamId = useTeamId as jest.MockedFunction; +const mockUseTeamLeaveModal = useTeamLeaveModal as jest.MockedFunction< + typeof useTeamLeaveModal +>; +const mockUseSuspenseQuery = useSuspenseQuery as jest.Mock; + +describe("MemberList", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseTeamId.mockReturnValue("1"); + mockUseTeamLeaveModal.mockReturnValue({ + openLeaveTeamModal: mockOpenLeaveTeamModal, + closeLeaveTeamModal: jest.fn(), + }); + }); + + describe("기본 렌더링", () => { + beforeEach(() => { + mockUseSuspenseQuery + .mockReturnValueOnce({ data: mockMembers }) + .mockReturnValueOnce({ data: { id: 300 } }); + }); + + test("멤버 목록이 렌더링된다", () => { + render(); + expect(screen.getAllByTestId("member-item")).toHaveLength(2); + }); + + test("멤버 수가 표시된다", () => { + render(); + expect(screen.getByText("2명")).toBeInTheDocument(); + }); + }); + + describe("관리자 권한", () => { + beforeEach(() => { + mockUseSuspenseQuery + .mockReturnValueOnce({ data: mockMembers }) + .mockReturnValueOnce({ data: { id: 200 } }); + }); + + test("현재 사용자가 관리자이면 팀 나가기 버튼이 표시된다", () => { + render(); + expect( + screen.getByRole("button", { name: /팀 나가기/ }), + ).toBeInTheDocument(); + }); + + test("팀 나가기 버튼 클릭 시 openLeaveTeamModal이 호출된다", async () => { + render(); + await userEvent.click(screen.getByRole("button", { name: /팀 나가기/ })); + expect(mockOpenLeaveTeamModal).toHaveBeenCalledTimes(1); + }); + }); + + describe("일반 멤버 권한", () => { + test("현재 사용자가 일반 멤버이면 팀 나가기 버튼이 표시되지 않는다", () => { + mockUseSuspenseQuery + .mockReturnValueOnce({ data: mockMembers }) + .mockReturnValueOnce({ data: { id: 100 } }); + + render(); + expect( + screen.queryByRole("button", { name: /팀 나가기/ }), + ).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/widgets/team/MemberList/MemberList.tsx b/src/widgets/team/MemberList/MemberList.tsx index a69896c6..3f829418 100644 --- a/src/widgets/team/MemberList/MemberList.tsx +++ b/src/widgets/team/MemberList/MemberList.tsx @@ -41,15 +41,18 @@ export default function MemberListComponent() {
    {isMeAdmin && ( - - + - 팀 나가기 - + } + onClick={openLeaveTeamModal} + > + 팀 나가기 )}
    @@ -61,7 +64,6 @@ export default function MemberListComponent() { avatar={member.profileImageUrl ?? ""} nickName={member.userNickname} email={member.userEmail} - isAdmin={member.role === "ADMIN"} isMe={member.userId === me.id} /> ))} From 466411eccf11a37a340e852ae291bb5b6e736567 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A2=85=EC=A7=84=28SugarSyrup=29?= Date: Thu, 30 Apr 2026 20:23:44 +0900 Subject: [PATCH 6/7] =?UTF-8?q?refactor(#251):=20entities=20=EB=8B=A8?= =?UTF-8?q?=EA=B3=84=EC=97=90=20public=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../[inviteCode]/InvitationPageClient.tsx | 6 ++--- .../team/[teamId]/management/page.tsx | 2 +- src/entities/team/index.ts | 23 +++++++++++++++++++ src/widgets/NavigationBar/Team/Team.tsx | 2 +- src/widgets/home/FavoriteGoalsSection.tsx | 2 +- src/widgets/management/MemberList.tsx | 7 ++---- src/widgets/team/createForm/Form.tsx | 2 +- src/widgets/trash/Trash.tsx | 2 +- 8 files changed, 33 insertions(+), 13 deletions(-) diff --git a/src/app/taskmate/invitations/[inviteCode]/InvitationPageClient.tsx b/src/app/taskmate/invitations/[inviteCode]/InvitationPageClient.tsx index eb12bb3f..95a0fa81 100644 --- a/src/app/taskmate/invitations/[inviteCode]/InvitationPageClient.tsx +++ b/src/app/taskmate/invitations/[inviteCode]/InvitationPageClient.tsx @@ -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"; diff --git a/src/app/taskmate/team/[teamId]/management/page.tsx b/src/app/taskmate/team/[teamId]/management/page.tsx index 5d4aa064..9d2a4854 100644 --- a/src/app/taskmate/team/[teamId]/management/page.tsx +++ b/src/app/taskmate/team/[teamId]/management/page.tsx @@ -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"; diff --git a/src/entities/team/index.ts b/src/entities/team/index.ts index ec946b1d..93e76efd 100644 --- a/src/entities/team/index.ts +++ b/src/entities/team/index.ts @@ -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"; diff --git a/src/widgets/NavigationBar/Team/Team.tsx b/src/widgets/NavigationBar/Team/Team.tsx index 3ab06779..194f1522 100644 --- a/src/widgets/NavigationBar/Team/Team.tsx +++ b/src/widgets/NavigationBar/Team/Team.tsx @@ -3,7 +3,7 @@ import { useSuspenseQuery } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; -import { teamQueries } from "@/entities/team/query/team.queryKey"; +import { teamQueries } from "@/entities/team"; import { Spacing } from "@/shared/ui/Spacing"; import { formatNavigationKey } from "@/widgets/NavigationBar/utils/formatNavigationKey"; diff --git a/src/widgets/home/FavoriteGoalsSection.tsx b/src/widgets/home/FavoriteGoalsSection.tsx index 55e6d6cc..86c32110 100644 --- a/src/widgets/home/FavoriteGoalsSection.tsx +++ b/src/widgets/home/FavoriteGoalsSection.tsx @@ -3,7 +3,7 @@ import { useRouter } from "next/navigation"; import { useEffect, useRef, useState } from "react"; -import { FavoriteGoalItem } from "@/entities/goal/types/favorite.types"; +import { FavoriteGoalItem } from "@/entities/goal"; import { useInfiniteScroll } from "@/shared/hooks/useInfiniteScroll/useInfiniteScroll"; import Button from "@/shared/ui/Button/Button/Button"; import { Icon } from "@/shared/ui/Icon"; diff --git a/src/widgets/management/MemberList.tsx b/src/widgets/management/MemberList.tsx index 4dcd38c1..53bec00f 100644 --- a/src/widgets/management/MemberList.tsx +++ b/src/widgets/management/MemberList.tsx @@ -6,11 +6,8 @@ import { useParams } from "next/navigation"; import { useEffect, useState } from "react"; import { userQueries } from "@/entities/auth/query/user.queryKey"; -import { memberListApi } from "@/entities/team/api/management.api"; -import { memberApi } from "@/entities/team/api/management.api"; -import { memberRoleApi } from "@/entities/team/api/management.api"; -import { MemberData } from "@/entities/team/types/management.types"; -import { MemberRole } from "@/entities/team/types/management.types"; +import type { MemberData, MemberRole } from "@/entities/team"; +import { memberApi, memberListApi, memberRoleApi } from "@/entities/team"; import { formatMemberList } from "@/features/team/utils/formatMemberList"; import Dropdown from "@/shared/hooks/useDropdown/Dropdown"; import Button from "@/shared/ui/Button/Button/Button"; diff --git a/src/widgets/team/createForm/Form.tsx b/src/widgets/team/createForm/Form.tsx index 7d10c2c6..ea4a81ba 100644 --- a/src/widgets/team/createForm/Form.tsx +++ b/src/widgets/team/createForm/Form.tsx @@ -1,6 +1,6 @@ "use client"; -import { TEAM_NAME_MAX_LENGTH } from "@/entities/team/types/team"; +import { TEAM_NAME_MAX_LENGTH } from "@/entities/team"; import { useCreateTeamForm } from "@/features/team/hooks/useCreateTeamForm"; import Button from "@/shared/ui/Button/Button/Button"; import Input from "@/shared/ui/Input/Input"; diff --git a/src/widgets/trash/Trash.tsx b/src/widgets/trash/Trash.tsx index 620fdcb6..a56b8762 100644 --- a/src/widgets/trash/Trash.tsx +++ b/src/widgets/trash/Trash.tsx @@ -2,7 +2,7 @@ import { useSuspenseQuery } from "@tanstack/react-query"; import React, { useState } from "react"; -import { teamQueries } from "@/entities/team/query/team.queryKey"; +import { teamQueries } from "@/entities/team"; import PersonalTrash from "@/widgets/trash/PersonalTrash"; import TeamTrash from "@/widgets/trash/TeamTrash"; import TeamTrashDropdown from "@/widgets/trash/TeamTrash/TeamTrashDropdown"; From a4c209a2767e5a64d8a3c59824e068f9a206db25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A2=85=EC=A7=84=28SugarSyrup=29?= Date: Fri, 1 May 2026 10:11:19 +0900 Subject: [PATCH 7/7] refactor(#251): features/team refactor --- .../team/hooks/useCreateTeamForm/index.ts | 1 + .../useCreateTeamForm.test.ts | 182 ++++++++++++++++++ .../useCreateTeamForm.ts | 7 +- src/features/team/hooks/useTeamLeaveModal.tsx | 49 ++--- src/features/team/index.ts | 8 + src/features/team/management.utils.ts | 26 --- src/features/team/mock/index.ts | 3 + .../team/mutation/useCreateTeamMutation.ts | 31 +-- .../team/mutation/useLeaveTeamMutation.ts | 29 +++ src/features/team/utils/validateEmail.ts | 13 ++ src/shared/mock/handlers.ts | 8 +- .../goal/CreateForm/TeamCreateForm.tsx | 2 +- .../goal/Summary/GoalInfo/GoalInfo.tsx | 2 +- src/widgets/home/FavoriteGoalsItem.tsx | 2 +- src/widgets/management/InviteModal.tsx | 3 +- src/widgets/management/MemberList.tsx | 2 +- src/widgets/management/TeamNameEditor.tsx | 2 +- src/widgets/team/GoalList/GoalList.tsx | 4 +- src/widgets/team/GoalProgressCard/index.ts | 1 + .../team/MemberList/MemberList.test.tsx | 11 +- src/widgets/team/MemberList/MemberList.tsx | 8 +- src/widgets/team/Summary/Summary.tsx | 2 +- src/widgets/team/createForm/Form.test.tsx | 4 +- src/widgets/team/createForm/Form.tsx | 15 +- 24 files changed, 312 insertions(+), 103 deletions(-) create mode 100644 src/features/team/hooks/useCreateTeamForm/index.ts create mode 100644 src/features/team/hooks/useCreateTeamForm/useCreateTeamForm.test.ts rename src/features/team/hooks/{ => useCreateTeamForm}/useCreateTeamForm.ts (84%) create mode 100644 src/features/team/index.ts delete mode 100644 src/features/team/management.utils.ts create mode 100644 src/features/team/mock/index.ts create mode 100644 src/features/team/mutation/useLeaveTeamMutation.ts create mode 100644 src/features/team/utils/validateEmail.ts create mode 100644 src/widgets/team/GoalProgressCard/index.ts diff --git a/src/features/team/hooks/useCreateTeamForm/index.ts b/src/features/team/hooks/useCreateTeamForm/index.ts new file mode 100644 index 00000000..e6023b19 --- /dev/null +++ b/src/features/team/hooks/useCreateTeamForm/index.ts @@ -0,0 +1 @@ +export { useCreateTeamForm } from "./useCreateTeamForm"; diff --git a/src/features/team/hooks/useCreateTeamForm/useCreateTeamForm.test.ts b/src/features/team/hooks/useCreateTeamForm/useCreateTeamForm.test.ts new file mode 100644 index 00000000..2e19b413 --- /dev/null +++ b/src/features/team/hooks/useCreateTeamForm/useCreateTeamForm.test.ts @@ -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; +}; + +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: "잠시 후 다시 시도해주세요.", + }); + }); + }); +}); diff --git a/src/features/team/hooks/useCreateTeamForm.ts b/src/features/team/hooks/useCreateTeamForm/useCreateTeamForm.ts similarity index 84% rename from src/features/team/hooks/useCreateTeamForm.ts rename to src/features/team/hooks/useCreateTeamForm/useCreateTeamForm.ts index cf9c2d52..46d0e99e 100644 --- a/src/features/team/hooks/useCreateTeamForm.ts +++ b/src/features/team/hooks/useCreateTeamForm/useCreateTeamForm.ts @@ -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: "팀 생성 완료", diff --git a/src/features/team/hooks/useTeamLeaveModal.tsx b/src/features/team/hooks/useTeamLeaveModal.tsx index 38425c2d..f97fd3ab 100644 --- a/src/features/team/hooks/useTeamLeaveModal.tsx +++ b/src/features/team/hooks/useTeamLeaveModal.tsx @@ -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"; @@ -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, overlay.close()} + onConfirm={() => leaveMutation.mutate(teamId)} />, ); }; return { openLeaveTeamModal, - closeLeaveTeamModal, }; }; diff --git a/src/features/team/index.ts b/src/features/team/index.ts new file mode 100644 index 00000000..0a38fcc4 --- /dev/null +++ b/src/features/team/index.ts @@ -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"; diff --git a/src/features/team/management.utils.ts b/src/features/team/management.utils.ts deleted file mode 100644 index 9de48d8d..00000000 --- a/src/features/team/management.utils.ts +++ /dev/null @@ -1,26 +0,0 @@ -// validateTeamName -// @TODO: zod로 맞추기 (리팩토링) -export const validateTeamName = (raw: string): string | null => { - const value = raw.trim(); // 앞뒤 공백만 제거 - - //if (!value)는 value가 빈 문자열("")이면 true - if (!value) return "팀명을 입력해주세요."; - - return null; -}; - -// validateEmail -// @TODO: zod로 맞추기 (리팩토링) -export const validateEmail = (raw: string): string | null => { - const value = raw.trim(); - - if (!value) return "이메일을 입력해주세요."; - - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - - if (!emailRegex.test(value)) { - return "올바른 이메일 형식이 아닙니다."; - } - - return null; -}; diff --git a/src/features/team/mock/index.ts b/src/features/team/mock/index.ts new file mode 100644 index 00000000..6797846a --- /dev/null +++ b/src/features/team/mock/index.ts @@ -0,0 +1,3 @@ +export { invitationsHandlers } from "./invitations"; +export { managementHandler } from "./management"; +export { teamsHandlers } from "./teams"; diff --git a/src/features/team/mutation/useCreateTeamMutation.ts b/src/features/team/mutation/useCreateTeamMutation.ts index 532922b2..df8253e6 100644 --- a/src/features/team/mutation/useCreateTeamMutation.ts +++ b/src/features/team/mutation/useCreateTeamMutation.ts @@ -1,20 +1,29 @@ "use client"; -import { useMutation, UseMutationOptions } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { teamApi } from "@/entities/team"; -import type { ApiError, ApiResponse } from "@/shared/lib/api/types"; +import { teamApi, teamQueryOptions } from "@/entities/team"; +import type { ApiError } from "@/shared/lib/api/types"; -type UseCreateTeamMutationParams = UseMutationOptions< - ApiResponse, - ApiError, - string, - unknown ->; +type UseCreateTeamMutationOptions = { + onSuccess?: () => void; + onError?: (error: ApiError) => void; +}; + +export const useCreateTeamMutation = ({ + onSuccess, + onError, +}: UseCreateTeamMutationOptions = {}) => { + const queryClient = useQueryClient(); -export const useCreateTeamMutation = (options: UseCreateTeamMutationParams) => { return useMutation({ mutationFn: teamApi.create, - ...options, + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: teamQueryOptions.all().queryKey, + }); + onSuccess?.(); + }, + onError, }); }; diff --git a/src/features/team/mutation/useLeaveTeamMutation.ts b/src/features/team/mutation/useLeaveTeamMutation.ts new file mode 100644 index 00000000..b974dd58 --- /dev/null +++ b/src/features/team/mutation/useLeaveTeamMutation.ts @@ -0,0 +1,29 @@ +"use client"; + +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { teamApi, teamQueryOptions } from "@/entities/team"; +import type { ApiError } from "@/shared/lib/api/types"; + +type UseLeaveTeamMutationOptions = { + onSuccess?: () => void; + onError?: (error: ApiError) => void; +}; + +export const useLeaveTeamMutation = ({ + onSuccess, + onError, +}: UseLeaveTeamMutationOptions = {}) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (teamId: string) => teamApi.quitTeam(teamId), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: teamQueryOptions.all().queryKey, + }); + onSuccess?.(); + }, + onError, + }); +}; diff --git a/src/features/team/utils/validateEmail.ts b/src/features/team/utils/validateEmail.ts new file mode 100644 index 00000000..2d8739d2 --- /dev/null +++ b/src/features/team/utils/validateEmail.ts @@ -0,0 +1,13 @@ +export const validateEmail = (raw: string): string | null => { + const value = raw.trim(); + + if (!value) return "이메일을 입력해주세요."; + + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + + if (!emailRegex.test(value)) { + return "올바른 이메일 형식이 아닙니다."; + } + + return null; +}; diff --git a/src/shared/mock/handlers.ts b/src/shared/mock/handlers.ts index f9d8fb3f..67e109ff 100644 --- a/src/shared/mock/handlers.ts +++ b/src/shared/mock/handlers.ts @@ -1,8 +1,10 @@ import { authHandlers } from "@/features/auth/mock/auth"; import { goalsHandlers } from "@/features/goal/mock/goals"; -import { invitationsHandlers } from "@/features/team/mock/invitations"; -import { managementHandler } from "@/features/team/mock/management"; -import { teamsHandlers } from "@/features/team/mock/teams"; +import { + invitationsHandlers, + managementHandler, + teamsHandlers, +} from "@/features/team/mock"; import { todosHandlers } from "@/features/todo/mock/todos"; export const handlers = [ diff --git a/src/widgets/goal/CreateForm/TeamCreateForm.tsx b/src/widgets/goal/CreateForm/TeamCreateForm.tsx index ebfec5ec..48f8a890 100644 --- a/src/widgets/goal/CreateForm/TeamCreateForm.tsx +++ b/src/widgets/goal/CreateForm/TeamCreateForm.tsx @@ -5,7 +5,7 @@ import { useState } from "react"; import { createGoalSchema } from "@/entities/goal"; import { useCreateTeamGoalMutation } from "@/features/goal/mutation/useCreateTeamGoalMutation"; -import { useTeamId } from "@/features/team/hooks/useTeamId"; +import { useTeamId } from "@/features/team"; import Button from "@/shared/ui/Button/Button/Button"; import TextButton from "@/shared/ui/Button/TextButton/TextButton"; import Input from "@/shared/ui/Input/Input"; diff --git a/src/widgets/goal/Summary/GoalInfo/GoalInfo.tsx b/src/widgets/goal/Summary/GoalInfo/GoalInfo.tsx index 07ebe0d7..d04378c8 100644 --- a/src/widgets/goal/Summary/GoalInfo/GoalInfo.tsx +++ b/src/widgets/goal/Summary/GoalInfo/GoalInfo.tsx @@ -5,7 +5,7 @@ import { useState } from "react"; import { goalQueryOptions } from "@/entities/goal"; import { useGoalActions } from "@/features/goal/hooks/useGoalActions"; -import { useOptionalTeamId } from "@/features/team/hooks/useOptionalTeamId"; +import { useOptionalTeamId } from "@/features/team"; import { useGoalId } from "@/shared/hooks/useGoalId"; import { Icon } from "@/shared/ui/Icon"; import { cn } from "@/shared/utils/styles/cn"; diff --git a/src/widgets/home/FavoriteGoalsItem.tsx b/src/widgets/home/FavoriteGoalsItem.tsx index 2b191e04..d6b3775b 100644 --- a/src/widgets/home/FavoriteGoalsItem.tsx +++ b/src/widgets/home/FavoriteGoalsItem.tsx @@ -1,6 +1,6 @@ "use client"; -import { GoalProgressCard } from "@/widgets/team/GoalProgressCard/GoalProgressCard"; +import { GoalProgressCard } from "@/widgets/team/GoalProgressCard"; export interface FavoriteGoalsItemProps { teamId: number; diff --git a/src/widgets/management/InviteModal.tsx b/src/widgets/management/InviteModal.tsx index bbd805b5..40c73fcd 100644 --- a/src/widgets/management/InviteModal.tsx +++ b/src/widgets/management/InviteModal.tsx @@ -3,8 +3,7 @@ import { useEffect, useState } from "react"; import { teamApi } from "@/entities/team"; -import { useTeamId } from "@/features/team/hooks/useTeamId"; -import { validateEmail } from "@/features/team/management.utils"; +import { useTeamId, validateEmail } from "@/features/team"; import Button from "@/shared/ui/Button/Button/Button"; import Input from "@/shared/ui/Input/Input"; diff --git a/src/widgets/management/MemberList.tsx b/src/widgets/management/MemberList.tsx index 53bec00f..cc94e719 100644 --- a/src/widgets/management/MemberList.tsx +++ b/src/widgets/management/MemberList.tsx @@ -8,7 +8,7 @@ import { useEffect, useState } from "react"; import { userQueries } from "@/entities/auth/query/user.queryKey"; import type { MemberData, MemberRole } from "@/entities/team"; import { memberApi, memberListApi, memberRoleApi } from "@/entities/team"; -import { formatMemberList } from "@/features/team/utils/formatMemberList"; +import { formatMemberList } from "@/features/team"; import Dropdown from "@/shared/hooks/useDropdown/Dropdown"; import Button from "@/shared/ui/Button/Button/Button"; import ConfirmModal from "@/widgets/management/ConfirmModal"; diff --git a/src/widgets/management/TeamNameEditor.tsx b/src/widgets/management/TeamNameEditor.tsx index 0b5eaf0a..47f54d9a 100644 --- a/src/widgets/management/TeamNameEditor.tsx +++ b/src/widgets/management/TeamNameEditor.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react"; import { teamApi } from "@/entities/team"; -import { useTeamId } from "@/features/team/hooks/useTeamId"; +import { useTeamId } from "@/features/team"; import Button from "@/shared/ui/Button/Button/Button"; import Input from "@/shared/ui/Input/Input"; diff --git a/src/widgets/team/GoalList/GoalList.tsx b/src/widgets/team/GoalList/GoalList.tsx index f5352c10..6b8f9e5c 100644 --- a/src/widgets/team/GoalList/GoalList.tsx +++ b/src/widgets/team/GoalList/GoalList.tsx @@ -3,11 +3,11 @@ import { useMemo, useState } from "react"; import { goalQueryOptions, SortType } from "@/entities/goal"; -import { useTeamId } from "@/features/team/hooks/useTeamId"; +import { useTeamId } from "@/features/team"; import { useInfiniteScroll } from "@/shared/hooks/useInfiniteScroll"; import { Icon } from "@/shared/ui/Icon"; import { Order } from "@/shared/ui/Order"; -import { GoalProgressCard } from "@/widgets/team/GoalProgressCard/GoalProgressCard"; +import { GoalProgressCard } from "@/widgets/team/GoalProgressCard"; const sortOptions = ["최신순", "오래된순"]; diff --git a/src/widgets/team/GoalProgressCard/index.ts b/src/widgets/team/GoalProgressCard/index.ts new file mode 100644 index 00000000..4efa9969 --- /dev/null +++ b/src/widgets/team/GoalProgressCard/index.ts @@ -0,0 +1 @@ +export { GoalProgressCard } from "./GoalProgressCard"; diff --git a/src/widgets/team/MemberList/MemberList.test.tsx b/src/widgets/team/MemberList/MemberList.test.tsx index 332146d3..ac0edb60 100644 --- a/src/widgets/team/MemberList/MemberList.test.tsx +++ b/src/widgets/team/MemberList/MemberList.test.tsx @@ -2,8 +2,7 @@ import { useSuspenseQuery } from "@tanstack/react-query"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { useTeamId } from "@/features/team/hooks/useTeamId"; -import { useTeamLeaveModal } from "@/features/team/hooks/useTeamLeaveModal"; +import { useTeamId, useTeamLeaveModal } from "@/features/team"; import MemberListComponent from "./MemberList"; @@ -12,15 +11,9 @@ jest.mock("@tanstack/react-query", () => ({ useSuspenseQuery: jest.fn(), })); -jest.mock("@/features/team/hooks/useTeamId", () => ({ +jest.mock("@/features/team", () => ({ useTeamId: jest.fn(), -})); - -jest.mock("@/features/team/hooks/useTeamLeaveModal", () => ({ useTeamLeaveModal: jest.fn(), -})); - -jest.mock("@/features/team/utils/formatMemberList", () => ({ formatMemberList: jest.fn((members: T[]) => members), })); diff --git a/src/widgets/team/MemberList/MemberList.tsx b/src/widgets/team/MemberList/MemberList.tsx index 3f829418..87b9e11a 100644 --- a/src/widgets/team/MemberList/MemberList.tsx +++ b/src/widgets/team/MemberList/MemberList.tsx @@ -4,9 +4,11 @@ import { useSuspenseQuery } from "@tanstack/react-query"; import { userQueries } from "@/entities/auth/query/user.queryKey"; import { teamQueries } from "@/entities/team/query/team.queryKey"; -import { useTeamId } from "@/features/team/hooks/useTeamId"; -import { useTeamLeaveModal } from "@/features/team/hooks/useTeamLeaveModal"; -import { formatMemberList } from "@/features/team/utils/formatMemberList"; +import { + formatMemberList, + useTeamId, + useTeamLeaveModal, +} from "@/features/team"; import TextButton from "@/shared/ui/Button/TextButton/TextButton"; import { Icon } from "@/shared/ui/Icon"; diff --git a/src/widgets/team/Summary/Summary.tsx b/src/widgets/team/Summary/Summary.tsx index 7f1e08b1..3f9ea9af 100644 --- a/src/widgets/team/Summary/Summary.tsx +++ b/src/widgets/team/Summary/Summary.tsx @@ -5,7 +5,7 @@ import Image from "next/image"; import { useRouter } from "next/navigation"; import { teamQueryOptions } from "@/entities/team"; -import { useTeamId } from "@/features/team/hooks/useTeamId"; +import { useTeamId } from "@/features/team"; import SettingIcon from "@/shared/assets/images/setting.png"; import { ProgressBar } from "@/shared/ui/ProgressBar"; import { cn } from "@/shared/utils/styles/cn"; diff --git a/src/widgets/team/createForm/Form.test.tsx b/src/widgets/team/createForm/Form.test.tsx index ca1819aa..084bead0 100644 --- a/src/widgets/team/createForm/Form.test.tsx +++ b/src/widgets/team/createForm/Form.test.tsx @@ -1,7 +1,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { fireEvent, render, screen } from "@testing-library/react"; -import { useCreateTeamForm } from "@/features/team/hooks/useCreateTeamForm"; +import { useCreateTeamForm } from "@/features/team"; import ToastProvider from "@/shared/providers/ToastProvider"; import Form from "./Form"; @@ -29,7 +29,7 @@ jest.mock("next/navigation", () => ({ })); const handleSubmitMock = jest.fn(); -jest.mock("@/features/team/hooks/useCreateTeamForm", () => ({ +jest.mock("@/features/team", () => ({ useCreateTeamForm: () => ({ handleSubmit: handleSubmitMock, nameError: "", diff --git a/src/widgets/team/createForm/Form.tsx b/src/widgets/team/createForm/Form.tsx index ea4a81ba..c25287f2 100644 --- a/src/widgets/team/createForm/Form.tsx +++ b/src/widgets/team/createForm/Form.tsx @@ -1,7 +1,7 @@ "use client"; import { TEAM_NAME_MAX_LENGTH } from "@/entities/team"; -import { useCreateTeamForm } from "@/features/team/hooks/useCreateTeamForm"; +import { useCreateTeamForm } from "@/features/team"; import Button from "@/shared/ui/Button/Button/Button"; import Input from "@/shared/ui/Input/Input"; @@ -10,13 +10,18 @@ export default function Form() { return (
    - - -
    +
    +