diff --git a/public/search-icon.svg b/public/search-icon.svg new file mode 100644 index 0000000..47ef5b2 --- /dev/null +++ b/public/search-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/api/services/common/fixtures.ts b/src/api/services/common/fixtures.ts index e52f53a..1c5088c 100644 --- a/src/api/services/common/fixtures.ts +++ b/src/api/services/common/fixtures.ts @@ -7,7 +7,12 @@ import type { GitHubTeam, GitHubTree, } from "../../infra/gitHub/types"; -import { Grade, type MemberIdentity, type Submission } from "./types"; +import { + Grade, + type Member, + type MemberIdentity, + type Submission, +} from "./types"; export const dummyStudyConfig: StudyConfig = { organization: "test-org", @@ -163,33 +168,53 @@ export const mockMembers = mockGitHubMembers.map((member) => ({ grade: member.login === "algo" ? Grade.BIG_TREE : Grade.SPROUT, })); -export const createMockMemberIdentity = ( +export function createMockMemberIdentity( customMember: Partial = {}, -): MemberIdentity => ({ - id: faker.internet.userName().toLowerCase(), - name: faker.internet.userName(), - cohort: faker.number.int({ min: 1, max: 10 }), - profileUrl: faker.internet.url(), - ...customMember, -}); +): MemberIdentity { + return { + id: faker.internet.userName().toLowerCase(), + name: faker.internet.userName(), + cohort: faker.number.int({ min: 1, max: 10 }), + profileUrl: faker.internet.url(), + ...customMember, + }; +} -export const createMockSubmission = ( +export function createMockSubmission( customSubmission: Partial = {}, -): Submission => ({ - memberId: faker.internet.userName(), - problemTitle: faker.word.words().replaceAll(" ", "-"), - language: faker.helpers.arrayElement(["js", "ts", "py"]), - ...customSubmission, -}); +): Submission { + return { + memberId: faker.internet.userName(), + problemTitle: faker.word.words().replaceAll(" ", "-"), + language: faker.helpers.arrayElement(["js", "ts", "py"]), + ...customSubmission, + }; +} -export const createMockSubmissions = ( +export function createMockSubmissions( memberId: string, count: number, -): Submission[] => { +): Submission[] { return faker.helpers.arrayElements(problems, count).map((problem) => createMockSubmission({ memberId, problemTitle: problem.title, }), ); -}; +} + +export function createMockMember(custom: Partial = {}): Member { + return { + id: faker.string.uuid(), + name: faker.person.fullName(), + cohort: faker.number.int({ min: 1, max: 10 }), + profileUrl: faker.internet.url(), + progress: faker.number.int({ min: 0, max: 100 }), + grade: faker.helpers.arrayElement(Object.values(Grade)), + solvedProblems: faker.helpers.arrayElements( + problems, + faker.number.int({ min: 0, max: 5 }), + ), + ...custom, + }; +} diff --git a/src/api/services/common/types.ts b/src/api/services/common/types.ts index 9008b99..0fd4911 100644 --- a/src/api/services/common/types.ts +++ b/src/api/services/common/types.ts @@ -37,6 +37,5 @@ export interface Member { /** Unit: % */ progress: number; grade: Grade; - /** Example: ["best-time-to-buy-and-sell-stock", "3sum", "climbing-stairs", ...] */ solvedProblems: Problem[]; } diff --git a/src/components/Footer/Footer.module.css b/src/components/Footer/Footer.module.css index 00f0e57..5d16dff 100644 --- a/src/components/Footer/Footer.module.css +++ b/src/components/Footer/Footer.module.css @@ -1,4 +1,5 @@ .footer { + width: 100%; background-color: var(--text-900); padding: 80px 27px; diff --git a/src/components/Leaderboard/Leaderboard.module.css b/src/components/Leaderboard/Leaderboard.module.css index 9139fff..c599909 100644 --- a/src/components/Leaderboard/Leaderboard.module.css +++ b/src/components/Leaderboard/Leaderboard.module.css @@ -1,11 +1,46 @@ .leaderboard { - /* 컴포넌트가 헤더에 가려져서 임의로 지정했으니 컴포넌트 개발자가 설계에 맞게 수정해주시면 됩니다 */ + display: flex; + flex-direction: column; + align-items: center; + + width: 100%; +} + +.contentWrapper { + width: 100%; + max-width: 1080px; margin-top: 100px; - /* TODO remove after replacing the inline article with the card component */ - article { - border: 1px solid gray; - border-radius: 16px; - padding: 16px; - margin: 16px; + + ul { + display: flex; + flex-wrap: wrap; + gap: 25px; + + margin-top: 30px; + margin-bottom: 60px; + + li { + flex: 1; + } + } +} + +.toolbar { + display: flex; + flex-direction: column; + align-items: flex-start; + row-gap: 10px; + + width: 100%; + margin-bottom: 30px; + + @media (min-width: 768px) { + flex-direction: row; + justify-content: space-between; + align-items: center; + } + + h1 { + font-size: 24px; } } diff --git a/src/components/Leaderboard/Leaderboard.test.tsx b/src/components/Leaderboard/Leaderboard.test.tsx index b3ce175..50992d4 100644 --- a/src/components/Leaderboard/Leaderboard.test.tsx +++ b/src/components/Leaderboard/Leaderboard.test.tsx @@ -1,6 +1,8 @@ +import { faker } from "@faker-js/faker"; +import { render, screen } from "@testing-library/react"; import { expect, test, vi } from "vitest"; import { mock } from "vitest-mock-extended"; -import { render, screen } from "@testing-library/react"; + import type { Member } from "../../api/services/common/types"; import useMembers from "../../hooks/useMembers"; import Leaderboard from "./Leaderboard"; @@ -9,7 +11,14 @@ vi.mock("../../hooks/useMembers"); test("render the loading message while fetching members", () => { vi.mocked(useMembers).mockReturnValue( - mock({ isLoading: true, error: null, members: [] }), + mock({ + isLoading: true, + error: null, + members: [], + totalCohorts: 0, + filter: { name: "", cohort: null }, + setFilter: vi.fn(), + }), ); render(); @@ -19,7 +28,14 @@ test("render the loading message while fetching members", () => { test("render the error message while fetching members", () => { vi.mocked(useMembers).mockReturnValue( - mock({ isLoading: false, error: new Error(), members: [] }), + mock({ + isLoading: false, + error: new Error(), + members: [], + totalCohorts: 0, + filter: { name: "", cohort: null }, + setFilter: vi.fn(), + }), ); render(); @@ -29,7 +45,14 @@ test("render the error message while fetching members", () => { test("render the site header", () => { vi.mocked(useMembers).mockReturnValue( - mock({ isLoading: false, error: null, members: [] }), + mock({ + isLoading: false, + error: null, + members: [], + totalCohorts: 0, + filter: { name: "", cohort: null }, + setFilter: vi.fn(), + }), ); render(); @@ -40,7 +63,14 @@ test("render the site header", () => { test("render the page title", () => { vi.mocked(useMembers).mockReturnValue( - mock({ isLoading: false, error: null, members: [] }), + mock({ + isLoading: false, + error: null, + members: [], + totalCohorts: 0, + filter: { name: "", cohort: null }, + setFilter: vi.fn(), + }), ); render(); @@ -50,16 +80,23 @@ test("render the page title", () => { test("render the member cards", () => { const members = [ - mock(), - mock(), - mock(), - mock(), - mock(), - mock(), + mock({ name: faker.person.fullName() }), + mock({ name: faker.person.fullName() }), + mock({ name: faker.person.fullName() }), + mock({ name: faker.person.fullName() }), + mock({ name: faker.person.fullName() }), + mock({ name: faker.person.fullName() }), ]; vi.mocked(useMembers).mockReturnValue( - mock({ isLoading: false, error: null, members }), + mock({ + isLoading: false, + error: null, + members, + totalCohorts: 0, + filter: { name: "", cohort: null }, + setFilter: vi.fn(), + }), ); render(); @@ -71,10 +108,34 @@ test("render the member cards", () => { test("render the site footer", () => { vi.mocked(useMembers).mockReturnValue( - mock({ isLoading: false, error: null, members: [mock()] }), + mock({ + isLoading: false, + error: null, + members: [mock({ name: faker.person.fullName() })], + totalCohorts: 0, + filter: { name: "", cohort: null }, + setFilter: vi.fn(), + }), ); render(); expect(screen.getByRole("contentinfo", { name: "Site Footer" })); }); + +test("render the search bar", () => { + vi.mocked(useMembers).mockReturnValue( + mock({ + isLoading: false, + error: null, + members: [mock({ name: faker.person.fullName() })], + totalCohorts: 0, + filter: { name: "", cohort: null }, + setFilter: vi.fn(), + }), + ); + + render(); + + expect(screen.getByLabelText("Search Bar")); +}); diff --git a/src/components/Leaderboard/Leaderboard.tsx b/src/components/Leaderboard/Leaderboard.tsx index 7f37e74..08459d6 100644 --- a/src/components/Leaderboard/Leaderboard.tsx +++ b/src/components/Leaderboard/Leaderboard.tsx @@ -1,34 +1,53 @@ import Footer from "../Footer/Footer"; import Header from "../Header/Header"; +import SearchBar from "../SearchBar/SearchBar"; import { getMembers } from "../../api/services/store/storeService"; -import useMembers from "../../hooks/useMembers"; +import useMembers, { type Filter } from "../../hooks/useMembers"; import Card from "../Card/Card"; import styles from "./Leaderboard.module.css"; export default function Leaderboard() { - const { members, isLoading, error } = useMembers({ getMembers }); + const { members, isLoading, error, totalCohorts, filter, setFilter } = + useMembers({ getMembers }); + + const handleSearch = ({ name, cohort }: Filter): void => + setFilter({ name, cohort }); if (isLoading) return

Loading...

; // TODO replace with a proper loading component if (error) return

Error!

; // TODO replace with a proper error component + const sortedMembers = members.sort((a, b) => b.progress - a.progress); + return (
-

리더보드

-
    - {members.map((member) => ( -
  • - -
  • - ))} -
+ +
+
+

리더보드

+ +
+ +
    + {sortedMembers.map((member) => ( +
  • + +
  • + ))} +
+
+
); diff --git a/src/components/SearchBar/SearchBar.module.css b/src/components/SearchBar/SearchBar.module.css index e69de29..5f11d9e 100644 --- a/src/components/SearchBar/SearchBar.module.css +++ b/src/components/SearchBar/SearchBar.module.css @@ -0,0 +1,37 @@ +.searchBar { + display: flex; + align-items: center; + column-gap: 5px; + + border: 2px solid #846de9; + border-radius: 10px; + + width: max-content; + height: 40px; + padding: 13px 15px; + + img { + width: 16px; + height: 16px; + } + + input { + border: 0; + } + + div[role="separator"] { + width: 1px; + height: 16px; + border: 1px solid #846de9; + margin-right: 10px; + } + + select { + width: 75px; + border: 0; + + &:hover { + cursor: pointer; + } + } +} diff --git a/src/components/SearchBar/SearchBar.stories.tsx b/src/components/SearchBar/SearchBar.stories.tsx index cd77a5b..e323990 100644 --- a/src/components/SearchBar/SearchBar.stories.tsx +++ b/src/components/SearchBar/SearchBar.stories.tsx @@ -12,5 +12,6 @@ export const Default: StoryObj = { args: { onSearch: fn(), totalCohorts: 5, + filter: { name: "", cohort: null }, }, }; diff --git a/src/components/SearchBar/SearchBar.test.tsx b/src/components/SearchBar/SearchBar.test.tsx index c85afe4..4e1d123 100644 --- a/src/components/SearchBar/SearchBar.test.tsx +++ b/src/components/SearchBar/SearchBar.test.tsx @@ -1,25 +1,40 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { beforeEach, expect, test, vi } from "vitest"; +import type { Filter } from "../../hooks/useMembers"; import SearchBar from "./SearchBar"; const totalCohorts = 5; let onSearchMock: ReturnType; +let filter: Filter; beforeEach(() => { onSearchMock = vi.fn(); + filter = { name: "", cohort: null }; }); test("renders name input field", () => { - render(); + render( + , + ); expect(screen.getByPlaceholderText("검색")).toBeInTheDocument(); expect(screen.getByLabelText("이름 검색")).toBeInTheDocument(); }); test("renders select field and options by totalCohorts", () => { - render(); + render( + , + ); - expect(screen.getByText("기수")).toBeInTheDocument(); + expect(screen.getByText("전체 기수")).toBeInTheDocument(); expect(screen.getByLabelText("기수 선택")).toBeInTheDocument(); for (let i = 1; i <= totalCohorts; i++) { @@ -28,7 +43,13 @@ test("renders select field and options by totalCohorts", () => { }); test("calls onSearch after 0.3 seconds when name is entered", async () => { - render(); + render( + , + ); const input = screen.getByPlaceholderText("검색") as HTMLInputElement; @@ -41,14 +62,23 @@ test("calls onSearch after 0.3 seconds when name is entered", async () => { // Wait 300ms and check if onSearch was called await waitFor( () => { - expect(onSearchMock).toHaveBeenCalledWith("John", null); + expect(onSearchMock).toHaveBeenCalledWith({ + name: "John", + cohort: null, + }); }, { timeout: 300 }, ); }); test("debounces onSearch calls during name input", async () => { - render(); + render( + , + ); const input = screen.getByPlaceholderText("검색") as HTMLInputElement; @@ -62,36 +92,63 @@ test("debounces onSearch calls during name input", async () => { await waitFor( () => { expect(onSearchMock).toHaveBeenCalledTimes(1); - expect(onSearchMock).toHaveBeenCalledWith("John", null); + expect(onSearchMock).toHaveBeenCalledWith({ + name: "John", + cohort: null, + }); }, { timeout: 300 }, ); }); test("calls onSearch immediately when cohort is selected", () => { - render(); + render( + , + ); const select = screen.getByRole("combobox") as HTMLSelectElement; // Select cohort 2 fireEvent.change(select, { target: { value: "2" } }); - expect(onSearchMock).toHaveBeenCalledWith("", 2); + expect(onSearchMock).toHaveBeenCalledWith({ + name: "", + cohort: 2, + }); }); test("calls onSearch when only cohort is selected without name", () => { - render(); + render( + , + ); const select = screen.getByRole("combobox") as HTMLSelectElement; // Select cohort 3 fireEvent.change(select, { target: { value: "3" } }); - expect(onSearchMock).toHaveBeenCalledWith("", 3); + expect(onSearchMock).toHaveBeenCalledWith({ + name: "", + cohort: 3, + }); }); test("calls onSearch when name is empty and cohort is selected", () => { - render(); + render( + , + ); const input = screen.getByPlaceholderText("검색") as HTMLInputElement; const select = screen.getByRole("combobox") as HTMLSelectElement; @@ -103,5 +160,8 @@ test("calls onSearch when name is empty and cohort is selected", () => { fireEvent.change(input, { target: { value: "" } }); // Wait for trigger - expect(onSearchMock).toHaveBeenCalledWith("", 1); + expect(onSearchMock).toHaveBeenCalledWith({ + name: "", + cohort: 1, + }); }); diff --git a/src/components/SearchBar/SearchBar.tsx b/src/components/SearchBar/SearchBar.tsx index 69a63ff..df2f162 100644 --- a/src/components/SearchBar/SearchBar.tsx +++ b/src/components/SearchBar/SearchBar.tsx @@ -1,25 +1,40 @@ import React, { useEffect, useState } from "react"; +import type { Filter } from "../../hooks/useMembers"; + +import style from "./SearchBar.module.css"; + interface SearchBarProps { - onSearch: (name: string, cohort: number | null) => void; + filter: Filter; + onSearch: (filter: Filter) => void; totalCohorts: number; } -export default function SearchBar({ onSearch, totalCohorts }: SearchBarProps) { - const [name, setName] = useState(""); - const [cohort, setCohort] = useState(null); +export default function SearchBar({ + filter, + onSearch, + totalCohorts, +}: SearchBarProps) { const [debounceTimeout, setDebounceTimeout] = useState(null); + const [localName, setLocalName] = useState(filter.name); + + useEffect(() => { + setLocalName(filter.name); + }, [filter.name]); const handleNameChange = (event: React.ChangeEvent) => { - const value = event.target.value.trim(); - setName(value); + const value = event.target.value; + setLocalName(value); if (debounceTimeout) { clearTimeout(debounceTimeout); } const timeout = window.setTimeout(() => { - onSearch(value, cohort); + onSearch({ + name: value.trim(), + cohort: filter.cohort, + }); }, 200); setDebounceTimeout(timeout); @@ -28,38 +43,38 @@ export default function SearchBar({ onSearch, totalCohorts }: SearchBarProps) { const handleCohortChange = (event: React.ChangeEvent) => { const value = event.target.value; - setCohort(value === "" ? null : Number(value)); - onSearch(name, value === "" ? null : Number(value)); + onSearch({ + name: filter.name, + cohort: value ? parseInt(value) : null, + }); }; - useEffect(() => { - if (name === "" && cohort) { - onSearch(name, cohort); - } - }, [name, cohort, onSearch]); - return ( -
+
+ 검색 아이콘 + +
+ -
+ ); } diff --git a/src/hooks/useMembers.test.ts b/src/hooks/useMembers.test.ts index bb36341..abb02db 100644 --- a/src/hooks/useMembers.test.ts +++ b/src/hooks/useMembers.test.ts @@ -1,23 +1,15 @@ -import { faker } from "@faker-js/faker"; -import { renderHook, waitFor } from "@testing-library/react"; +import { act, renderHook, waitFor } from "@testing-library/react"; import { expect, test, vi } from "vitest"; -import { Grade, type Member } from "../api/services/common/types"; +import { createMockMember } from "../api/services/common/fixtures"; +import { type Member } from "../api/services/common/types"; import useMembers from "./useMembers"; test("fetch member info successfully and update state", async () => { - const expectedMembers: Member[] = Array.from({ length: 5 }, () => ({ - id: faker.string.uuid(), - name: faker.person.fullName(), - cohort: faker.number.int({ min: 1, max: 10 }), - profileUrl: faker.internet.url(), - progress: faker.number.int({ min: 0, max: 100 }), - grade: faker.helpers.arrayElement(Object.values(Grade)), - solvedProblems: Array.from({ length: 5 }, () => ({ - title: faker.lorem.words(3).replaceAll(" ", "-"), - difficulty: faker.helpers.arrayElement(["easy", "medium", "hard"]), - })), - })); + const expectedMembers: Member[] = [ + createMockMember({ cohort: 1 }), + createMockMember({ cohort: 2 }), + ]; const getMembers = vi.fn().mockResolvedValue(expectedMembers); @@ -27,12 +19,14 @@ test("fetch member info successfully and update state", async () => { expect(result.current.isLoading).toBe(true); expect(result.current.members).toEqual([]); expect(result.current.error).toBeNull(); + expect(result.current.totalCohorts).toBe(0); // Wait for the hook to finish fetching data await waitFor(() => expect(result.current.isLoading).toBe(false)); // Validate the updated state expect(result.current.members).toEqual(expectedMembers); + expect(result.current.totalCohorts).toBe(2); expect(result.current.error).toBeNull(); expect(getMembers).toHaveBeenCalledTimes(1); }); @@ -41,7 +35,6 @@ test("handle error when fetching member info fails", async () => { const mockError = new Error("Fetch error"); const getMembers = vi.fn().mockRejectedValue(mockError); - // Use the hook with the mocked getMembers function that rejects const { result } = renderHook(() => useMembers({ getMembers })); // Initial state validation @@ -57,3 +50,113 @@ test("handle error when fetching member info fails", async () => { expect(result.current.error).toEqual(mockError); expect(getMembers).toHaveBeenCalledTimes(1); }); + +test("filter members by name and cohort", async () => { + const expectedMembers: Member[] = [ + createMockMember({ name: "John Doe", cohort: 1 }), + createMockMember({ name: "Jane Doe", cohort: 2 }), + createMockMember({ name: "Alice Cooper", cohort: 1 }), + ]; + const [johnDoe1, janeDoe1, aliceCooper2] = expectedMembers; + + const getMembers = vi.fn().mockResolvedValue(expectedMembers); + + const { result } = renderHook(() => useMembers({ getMembers })); + + // Wait for members to load + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + // Initial state + expect(result.current.members).toEqual(expectedMembers); + + // Act: Apply name filter + act(() => { + result.current.setFilter({ + name: "Doe", + cohort: null, + }); + }); + + expect(result.current.members).toEqual([johnDoe1, janeDoe1]); + + // Act: Apply cohort filter + act(() => { + result.current.setFilter({ name: "", cohort: 1 }); + }); + + expect(result.current.members).toEqual([johnDoe1, aliceCooper2]); + + // Act: Apply both name and cohort filter + act(() => { + result.current.setFilter({ name: "John", cohort: 1 }); + }); + + expect(result.current.members).toEqual([johnDoe1]); +}); + +test("total cohorts calculated correctly", async () => { + const expectedMembers: Member[] = [ + createMockMember({ cohort: 1 }), + createMockMember({ cohort: 2 }), + createMockMember({ cohort: 2 }), + createMockMember({ cohort: 3 }), + createMockMember({ cohort: 3 }), + ]; + + const getMembers = vi.fn().mockResolvedValue(expectedMembers); + + const { result } = renderHook(() => useMembers({ getMembers })); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.totalCohorts).toBe(3); +}); + +test("filter members by name case-insensitively", async () => { + const expectedMembers: Member[] = [ + createMockMember({ name: "John Doe", cohort: 1 }), + createMockMember({ name: "jane doe", cohort: 2 }), + createMockMember({ name: "ALICE Cooper", cohort: 3 }), + ]; + const [johnDoe, janeDoe, aliceCooper] = expectedMembers; + + const getMembers = vi.fn().mockResolvedValue(expectedMembers); + + const { result } = renderHook(() => useMembers({ getMembers })); + + // Wait for members to load + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + // Initial state + expect(result.current.members).toEqual(expectedMembers); + + // Act: Apply case-insensitive name filter + act(() => { + result.current.setFilter({ + name: "doe", + cohort: null, + }); + }); + + expect(result.current.members).toEqual([johnDoe, janeDoe]); + + // Act: Apply case-insensitive name and cohort filter + act(() => { + result.current.setFilter({ + name: "aLiCe", + cohort: null, + }); + }); + + expect(result.current.members).toEqual([aliceCooper]); + + // Act: Apply case-insensitive partial name filter + act(() => { + result.current.setFilter({ + name: "JoHn", + cohort: null, + }); + }); + + expect(result.current.members).toEqual([johnDoe]); +}); diff --git a/src/hooks/useMembers.ts b/src/hooks/useMembers.ts index 0f93531..9382f54 100644 --- a/src/hooks/useMembers.ts +++ b/src/hooks/useMembers.ts @@ -1,16 +1,29 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import type { Member } from "../api/services/common/types"; type UseMembers = (params: { getMembers: () => Promise }) => { members: Member[]; + totalCohorts: number; isLoading: boolean; error: unknown | null; + filter: { name: string; cohort: number | null }; + setFilter: (filter: Filter) => void; +}; + +export type Filter = { + name: string; + cohort: number | null; }; const useMembers: UseMembers = function ({ getMembers }) { const [members, setMembers] = useState([]); + const [totalCohorts, setTotalCohorts] = useState(0); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); + const [filter, setFilter] = useState({ + name: "", + cohort: null, + }); useEffect(() => { async function fetchMemberInfo() { @@ -18,7 +31,10 @@ const useMembers: UseMembers = function ({ getMembers }) { try { const members = await getMembers(); + const totalCohorts = new Set(members.map((member) => member.cohort)) + .size; + setTotalCohorts(totalCohorts); setMembers(members); } catch (error) { console.error(error); @@ -31,10 +47,25 @@ const useMembers: UseMembers = function ({ getMembers }) { fetchMemberInfo(); }, [getMembers]); + const filteredMembers = useMemo( + () => + members + .filter((member) => + member.name.toLowerCase().includes(filter.name.toLowerCase()), + ) + .filter( + (member) => filter.cohort === null || member.cohort === filter.cohort, + ), + [filter.cohort, filter.name, members], + ); + return { - members, + members: filteredMembers, + totalCohorts, isLoading, error, + filter, + setFilter, }; };