Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add SearchBar to Leaderboard #84

Merged
merged 10 commits into from
Nov 30, 2024
Merged
3 changes: 3 additions & 0 deletions public/search-icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
63 changes: 44 additions & 19 deletions src/api/services/common/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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> = {},
): 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> = {},
): 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> = {}): 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,
};
}
1 change: 0 additions & 1 deletion src/api/services/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
}
1 change: 1 addition & 0 deletions src/components/Footer/Footer.module.css
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
.footer {
width: 100%;
background-color: var(--text-900);
padding: 80px 27px;

Expand Down
49 changes: 42 additions & 7 deletions src/components/Leaderboard/Leaderboard.module.css
Original file line number Diff line number Diff line change
@@ -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;
}
}
87 changes: 74 additions & 13 deletions src/components/Leaderboard/Leaderboard.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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(<Leaderboard />);
Expand All @@ -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(<Leaderboard />);
Expand All @@ -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(<Leaderboard />);
Expand All @@ -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(<Leaderboard />);
Expand All @@ -50,16 +80,23 @@ test("render the page title", () => {

test("render the member cards", () => {
const members = [
mock<Member>(),
mock<Member>(),
mock<Member>(),
mock<Member>(),
mock<Member>(),
mock<Member>(),
mock<Member>({ name: faker.person.fullName() }),
mock<Member>({ name: faker.person.fullName() }),
mock<Member>({ name: faker.person.fullName() }),
mock<Member>({ name: faker.person.fullName() }),
mock<Member>({ name: faker.person.fullName() }),
mock<Member>({ 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(<Leaderboard />);
Expand All @@ -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<Member>()] }),
mock({
isLoading: false,
error: null,
members: [mock<Member>({ name: faker.person.fullName() })],
totalCohorts: 0,
filter: { name: "", cohort: null },
setFilter: vi.fn(),
}),
);

render(<Leaderboard />);

expect(screen.getByRole("contentinfo", { name: "Site Footer" }));
});

test("render the search bar", () => {
vi.mocked(useMembers).mockReturnValue(
mock({
isLoading: false,
error: null,
members: [mock<Member>({ name: faker.person.fullName() })],
totalCohorts: 0,
filter: { name: "", cohort: null },
setFilter: vi.fn(),
}),
);

render(<Leaderboard />);

expect(screen.getByLabelText("Search Bar"));
});
49 changes: 34 additions & 15 deletions src/components/Leaderboard/Leaderboard.tsx
Original file line number Diff line number Diff line change
@@ -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 <p>Loading...</p>; // TODO replace with a proper loading component
if (error) return <p>Error!</p>; // TODO replace with a proper error component

const sortedMembers = members.sort((a, b) => b.progress - a.progress);

return (
<main className={styles.leaderboard}>
<Header />
<h1>리더보드</h1>
<ul>
{members.map((member) => (
<li key={member.id}>
<Card
id={member.id}
name={member.name}
cohort={member.cohort}
grade={member.grade}
/>
</li>
))}
</ul>

<div className={styles.contentWrapper}>
<section className={styles.toolbar}>
<h1>리더보드</h1>
<SearchBar
filter={filter}
onSearch={handleSearch}
totalCohorts={totalCohorts}
/>
</section>

<ul>
{sortedMembers.map((member) => (
<li key={member.id}>
<Card
id={member.id}
name={member.name}
cohort={member.cohort}
grade={member.grade}
/>
</li>
))}
</ul>
</div>

<Footer />
</main>
);
Expand Down
Loading