Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions packages/ui/src/components/filter-bar/filter-bar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";

import { FilterBar, type FilterBarProps } from "./filter-bar";

const difficultyOptions = [
{ label: "All", value: "all" },
{ label: "Beginner", value: "beginner" },
{ label: "Advanced", value: "advanced" },
];

const defaultProps: FilterBarProps = {
currentDifficulty: "",
currentTags: [],
difficultyOptions,
onFiltersChange: vi.fn(),
searchQuery: "",
tags: ["React", "TypeScript"],
};

function renderFilterBar(props: Partial<FilterBarProps> = {}) {
const onFiltersChange = vi.fn();
const view = render(
<FilterBar
{...defaultProps}
{...props}
onFiltersChange={onFiltersChange}
/>,
);

return { onFiltersChange, ...view };
}

describe("FilterBar", () => {
it("renders search, difficulty, and tag filters", () => {
renderFilterBar();

expect(screen.getByLabelText("Search")).toBeInTheDocument();
expect(screen.getByText("Difficulty:")).toBeInTheDocument();
expect(screen.getByText("Tags:")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "All" })).toBeInTheDocument();
expect(screen.getByText("React")).toBeInTheDocument();
});

it("emits search filter changes", () => {
const { onFiltersChange } = renderFilterBar();

fireEvent.change(screen.getByLabelText("Search"), {
target: { value: "forms" },
});

expect(onFiltersChange).toHaveBeenCalledWith({ search: "forms" });
});

it("emits difficulty changes", () => {
const { onFiltersChange } = renderFilterBar();

fireEvent.click(screen.getByRole("button", { name: "Advanced" }));

expect(onFiltersChange).toHaveBeenCalledWith({ difficulty: "advanced" });
});

it("toggles tags on and off", () => {
const { onFiltersChange, unmount } = renderFilterBar();

fireEvent.click(screen.getByText("React"));

expect(onFiltersChange).toHaveBeenCalledWith({ tags: ["React"] });
unmount();

const selected = renderFilterBar({ currentTags: ["React"] });
fireEvent.click(screen.getAllByText("React")[0]);

expect(selected.onFiltersChange).toHaveBeenCalledWith({ tags: [] });
});

it("clears selected tags", () => {
const { onFiltersChange } = renderFilterBar({ currentTags: ["React"] });

fireEvent.click(screen.getByRole("button", { name: "Clear" }));

expect(onFiltersChange).toHaveBeenCalledWith({ tags: [] });
});

it("summarizes active filters and clears all values", () => {
const { onFiltersChange } = renderFilterBar({
currentDifficulty: "advanced",
currentTags: ["React"],
searchQuery: "buttons",
});

const input = screen.getByLabelText("Search");
expect(screen.getByText("Active filters:")).toBeInTheDocument();
expect(screen.getByText('Search "buttons"')).toBeInTheDocument();

fireEvent.click(screen.getByRole("button", { name: "Clear all" }));

expect(onFiltersChange).toHaveBeenCalledWith({
difficulty: "all",
search: "",
tags: [],
});
expect(input).toHaveValue("");
});
});
140 changes: 140 additions & 0 deletions packages/ui/src/components/navbar-saas/navbar-saas.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import {
act,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";

import { SidebarProvider } from "../sidebar-provider";

import { NavbarSaas, type NavbarSaasProps } from "./navbar-saas";

let mockPathname = "/docs";

vi.mock("next/navigation", () => ({
usePathname: () => mockPathname,
}));

vi.mock("next/link", () => ({
default: ({
children,
href,
...props
}: React.AnchorHTMLAttributes<HTMLAnchorElement> & { href: string }) => (
<a href={href} {...props}>
{children}
</a>
),
}));

vi.mock("../theme-toggle", () => ({
ThemeToggle: () => (
<button aria-label="Toggle theme" type="button">
Theme
</button>
),
}));

const navItems = [
{ href: "/", title: "Home" },
{ href: "/docs", title: "Docs" },
{ href: "/blog", title: "Blog" },
];

function setViewportWidth(width: number) {
Object.defineProperty(window, "innerWidth", {
configurable: true,
value: width,
writable: true,
});
act(() => {
window.dispatchEvent(new Event("resize"));
});
}

function renderNavbar(props: Partial<NavbarSaasProps> = {}) {
return render(
<SidebarProvider>
<NavbarSaas
brand="Vllnt"
navItems={navItems}
rightSlot={<a href="/login">Login</a>}
{...props}
/>
</SidebarProvider>,
);
}

describe("NavbarSaas", () => {
beforeEach(() => {
mockPathname = "/docs";
setViewportWidth(1280);
});

it("renders brand, navigation links, right slot, and theme toggle", () => {
renderNavbar();

expect(screen.getByRole("link", { name: "Vllnt" })).toHaveAttribute(
"href",
"/",
);
expect(screen.getByRole("link", { name: "Docs" })).toHaveAttribute(
"href",
"/docs",
);
expect(screen.getByRole("link", { name: "Login" })).toBeInTheDocument();
expect(screen.getByLabelText("Toggle theme")).toBeInTheDocument();
});

it("marks the current pathname as active", () => {
renderNavbar();

expect(screen.getByRole("link", { name: "Docs" })).toHaveClass(
"text-foreground",
);
expect(screen.getByRole("link", { name: "Home" })).toHaveClass(
"text-foreground/60",
);
});

it("renders a custom React node brand", () => {
renderNavbar({
brand: <a href="/custom">Custom Brand</a>,
rightSlot: undefined,
});

expect(screen.getByRole("link", { name: "Custom Brand" })).toHaveAttribute(
"href",
"/custom",
);
});

it("toggles the mobile menu icon when the trigger is clicked", async () => {
setViewportWidth(390);
const { container } = renderNavbar();

await waitFor(() => {
expect(container.querySelector(".lucide-menu")).toBeInTheDocument();
});

const trigger = container.querySelector("button[class*='lg:hidden']");
if (!trigger) {
throw new Error("Expected mobile menu trigger to render");
}

fireEvent.click(trigger);

expect(container.querySelector(".lucide-x")).toBeInTheDocument();
});

it("does not render the mobile trigger when mobile menu is disabled", async () => {
setViewportWidth(390);
const { container } = renderNavbar({ showMobileMenu: false });

await waitFor(() => {
expect(container.querySelector(".lucide-menu")).not.toBeInTheDocument();
});
});
});
83 changes: 83 additions & 0 deletions packages/ui/src/components/search-bar/search-bar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";

import { SearchBar } from "./search-bar";

const mockReplace = vi.fn();
let mockSearchParameters = new URLSearchParams();

vi.mock("next/navigation", () => ({
useRouter: () => ({ replace: mockReplace }),
useSearchParams: () => mockSearchParameters,
}));

describe("SearchBar", () => {
afterEach(() => {
vi.useRealTimers();
mockReplace.mockClear();
mockSearchParameters = new URLSearchParams();
});

it("initializes the input from the search URL parameter", () => {
mockSearchParameters = new URLSearchParams("search=buttons");

render(<SearchBar />);

expect(screen.getByLabelText("Search posts...")).toHaveValue("buttons");
});

it("submits a trimmed query through the onSearch callback", () => {
const onSearch = vi.fn();
render(<SearchBar onSearch={onSearch} />);

fireEvent.change(screen.getByLabelText("Search posts..."), {
target: { value: " cards " },
});
fireEvent.click(screen.getByRole("button", { name: "Search" }));

expect(onSearch).toHaveBeenCalledWith("cards");
expect(mockReplace).not.toHaveBeenCalled();
});

it("updates the URL search parameter when no callback is provided", () => {
mockSearchParameters = new URLSearchParams("category=docs");
render(<SearchBar />);

fireEvent.change(screen.getByLabelText("Search posts..."), {
target: { value: "forms" },
});
fireEvent.click(screen.getByRole("button", { name: "Search" }));

expect(mockReplace).toHaveBeenCalledWith("?category=docs&search=forms");
});

it("removes the URL search parameter when submitting an empty query", () => {
mockSearchParameters = new URLSearchParams("category=docs&search=forms");
render(<SearchBar />);

fireEvent.change(screen.getByLabelText("Search posts..."), {
target: { value: " " },
});
fireEvent.click(screen.getByRole("button", { name: "Search" }));

expect(mockReplace).toHaveBeenCalledWith("?category=docs");
});

it("debounces typed changes before calling onSearch", () => {
vi.useFakeTimers();
const onSearch = vi.fn();
render(<SearchBar onSearch={onSearch} />);

fireEvent.change(screen.getByLabelText("Search posts..."), {
target: { value: "alerts" },
});

expect(onSearch).not.toHaveBeenCalled();

act(() => {
vi.advanceTimersByTime(300);
});

expect(onSearch).toHaveBeenCalledWith("alerts");
});
});
Loading
Loading