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,
};
};