Organization Filters UI

This commit is contained in:
yuneng-jiang
2026-01-10 17:06:00 -08:00
parent 707c2b55f5
commit e783a66f2a
8 changed files with 367 additions and 77 deletions
@@ -0,0 +1,125 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import OrganizationFilters, { FilterState } from "./OrganizationFilters";
describe("OrganizationFilters", () => {
const defaultFilters: FilterState = {
org_id: "",
org_alias: "",
sort_by: "",
sort_order: "asc",
};
it("should render", () => {
const onToggleFilters = vi.fn();
const onChange = vi.fn();
const onReset = vi.fn();
render(
<OrganizationFilters
filters={defaultFilters}
showFilters={false}
onToggleFilters={onToggleFilters}
onChange={onChange}
onReset={onReset}
/>,
);
expect(screen.getByPlaceholderText("Search by Organization Name")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /^filters$/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /reset filters/i })).toBeInTheDocument();
});
it("should show additional filters when showFilters is true", () => {
const onToggleFilters = vi.fn();
const onChange = vi.fn();
const onReset = vi.fn();
render(
<OrganizationFilters
filters={defaultFilters}
showFilters={true}
onToggleFilters={onToggleFilters}
onChange={onChange}
onReset={onReset}
/>,
);
expect(screen.getByPlaceholderText("Search by Organization ID")).toBeInTheDocument();
});
it("should call onChange when organization name input changes", async () => {
const user = userEvent.setup();
const onToggleFilters = vi.fn();
const onChange = vi.fn();
const onReset = vi.fn();
render(
<OrganizationFilters
filters={defaultFilters}
showFilters={false}
onToggleFilters={onToggleFilters}
onChange={onChange}
onReset={onReset}
/>,
);
const input = screen.getByPlaceholderText("Search by Organization Name");
await user.type(input, "test");
await waitFor(
() => {
expect(onChange).toHaveBeenCalledWith("org_alias", expect.any(String));
},
{ timeout: 500 },
);
});
it("should call onReset when reset button is clicked", async () => {
const user = userEvent.setup();
const onToggleFilters = vi.fn();
const onChange = vi.fn();
const onReset = vi.fn();
render(
<OrganizationFilters
filters={defaultFilters}
showFilters={false}
onToggleFilters={onToggleFilters}
onChange={onChange}
onReset={onReset}
/>,
);
const resetButton = screen.getByRole("button", { name: /reset filters/i });
await user.click(resetButton);
expect(onReset).toHaveBeenCalledTimes(1);
});
it("should show badge on filters button when filters are active", () => {
const onToggleFilters = vi.fn();
const onChange = vi.fn();
const onReset = vi.fn();
const filtersWithActive: FilterState = {
...defaultFilters,
org_alias: "test org",
};
render(
<OrganizationFilters
filters={filtersWithActive}
showFilters={false}
onToggleFilters={onToggleFilters}
onChange={onChange}
onReset={onReset}
/>,
);
const filtersButton = screen.getByRole("button", { name: /^filters$/i });
const badgeWrapper = filtersButton.closest(".ant-badge");
expect(badgeWrapper).toBeInTheDocument();
});
});
@@ -1,4 +1,7 @@
import React from "react";
import { FilterInput } from "@/components/common_components/Filters/FilterInput";
import { FiltersButton } from "@/components/common_components/Filters/FiltersButton";
import { ResetFiltersButton } from "@/components/common_components/Filters/ResetFiltersButton";
import { Search, User } from "lucide-react";
interface OrganizationFiltersProps {
filters: FilterState;
@@ -22,94 +25,39 @@ const OrganizationFilters = ({
onChange,
onReset,
}: OrganizationFiltersProps) => {
const hasActiveFilters = !!(filters.org_id || filters.org_alias);
return (
<div className="flex flex-col space-y-4">
{/* Search and Filter Controls */}
<div className="flex flex-wrap items-center gap-3">
{/* Organization Alias Search */}
<div className="relative w-64">
<input
type="text"
placeholder="Search by Organization Name..."
className="w-full px-3 py-2 pl-8 border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
value={filters.org_alias}
onChange={(e) => onChange("org_alias", e.target.value)}
/>
<svg
className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
<FilterInput
placeholder="Search by Organization Name"
value={filters.org_alias}
onChange={(value) => onChange("org_alias", value)}
icon={Search}
className="w-64"
/>
{/* Filter Button */}
<button
className={`px-3 py-2 text-sm border rounded-md hover:bg-gray-50 flex items-center gap-2 ${showFilters ? "bg-gray-100" : ""}`}
<FiltersButton
onClick={() => onToggleFilters(!showFilters)}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"
/>
</svg>
Filters
{(filters.org_id || filters.org_alias) && <span className="w-2 h-2 rounded-full bg-blue-500"></span>}
</button>
active={showFilters}
hasActiveFilters={hasActiveFilters}
/>
{/* Reset Filters Button */}
<button
className="px-3 py-2 text-sm border rounded-md hover:bg-gray-50 flex items-center gap-2"
onClick={onReset}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
Reset Filters
</button>
<ResetFiltersButton onClick={onReset} />
</div>
{/* Additional Filters */}
{showFilters && (
<div className="flex flex-wrap items-center gap-3 mt-3">
{/* Organization ID Search */}
<div className="relative w-64">
<input
type="text"
placeholder="Enter Organization ID"
className="w-full px-3 py-2 pl-8 border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
value={filters.org_id}
onChange={(e) => onChange("org_id", e.target.value)}
/>
<svg
className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5.121 17.804A13.937 13.937 0 0112 16c2.5 0 4.847.655 6.879 1.804M15 10a3 3 0 11-6 0 3 3 0 016 0zm6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<FilterInput
placeholder="Search by Organization ID"
value={filters.org_id}
onChange={(value) => onChange("org_id", value)}
icon={User}
className="w-64"
/>
</div>
)}
</div>
@@ -0,0 +1,59 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { FilterInput } from "./FilterInput";
describe("FilterInput", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
});
it("should render", () => {
const onChange = vi.fn();
render(<FilterInput value="" onChange={onChange} placeholder="Search..." />);
expect(screen.getByPlaceholderText("Search...")).toBeInTheDocument();
});
it("should call onChange with debounced value", async () => {
const onChange = vi.fn();
render(<FilterInput value="" onChange={onChange} placeholder="Search..." />);
const input = screen.getByPlaceholderText("Search...");
act(() => {
fireEvent.change(input, { target: { value: "test" } });
});
expect(onChange).not.toHaveBeenCalled();
act(() => {
vi.advanceTimersByTime(300);
});
expect(onChange).toHaveBeenCalledWith("test");
});
it("should display the value prop", () => {
const onChange = vi.fn();
render(<FilterInput value="initial value" onChange={onChange} placeholder="Search..." />);
const input = screen.getByPlaceholderText("Search...") as HTMLInputElement;
expect(input.value).toBe("initial value");
});
it("should update local value immediately when typing", async () => {
const onChange = vi.fn();
render(<FilterInput value="" onChange={onChange} placeholder="Search..." />);
const input = screen.getByPlaceholderText("Search...") as HTMLInputElement;
act(() => {
fireEvent.change(input, { target: { value: "a" } });
});
expect(input.value).toBe("a");
});
});
@@ -0,0 +1,51 @@
import { cx } from "@/lib/cva.config";
import { Input } from "antd";
import debounce from "lodash/debounce";
import { LucideIcon } from "lucide-react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
interface FilterInputProps {
placeholder?: string;
value: string;
onChange: (value: string) => void;
icon?: LucideIcon;
className?: string;
style?: React.CSSProperties;
}
const DEBOUNCE_DELAY = 300;
export const FilterInput: React.FC<FilterInputProps> = ({ placeholder, value, onChange, icon: Icon, className }) => {
const [localValue, setLocalValue] = useState(value);
useEffect(() => {
setLocalValue(value);
}, [value]);
const debouncedOnChange = useMemo(() => debounce((val: string) => onChange(val), DEBOUNCE_DELAY), [onChange]);
useEffect(() => {
return () => {
debouncedOnChange.cancel();
};
}, [debouncedOnChange]);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setLocalValue(newValue);
debouncedOnChange(newValue);
},
[debouncedOnChange],
);
return (
<Input
placeholder={placeholder}
value={localValue}
onChange={handleChange}
prefix={Icon ? <Icon size={16} className="text-gray-500" /> : undefined}
className={cx("w-64", className)}
/>
);
};
@@ -0,0 +1,37 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { FiltersButton } from "./FiltersButton";
describe("FiltersButton", () => {
it("should render", () => {
const onClick = vi.fn();
render(<FiltersButton onClick={onClick} active={false} hasActiveFilters={false} />);
expect(screen.getByRole("button", { name: /filters/i })).toBeInTheDocument();
});
it("should call onClick when clicked", async () => {
const user = userEvent.setup();
const onClick = vi.fn();
render(<FiltersButton onClick={onClick} active={false} hasActiveFilters={false} />);
const button = screen.getByRole("button", { name: /filters/i });
await user.click(button);
expect(onClick).toHaveBeenCalledTimes(1);
});
it("should show badge when hasActiveFilters is true", () => {
const onClick = vi.fn();
const { container } = render(<FiltersButton onClick={onClick} active={false} hasActiveFilters={true} />);
const button = screen.getByRole("button", { name: /filters/i });
const badgeWrapper = button.closest(".ant-badge");
expect(badgeWrapper).toBeInTheDocument();
});
it("should render custom label when provided", () => {
const onClick = vi.fn();
render(<FiltersButton onClick={onClick} active={false} hasActiveFilters={false} label="Advanced Filters" />);
expect(screen.getByRole("button", { name: /advanced filters/i })).toBeInTheDocument();
});
});
@@ -0,0 +1,25 @@
import { Badge, Button } from "antd";
import { Filter } from "lucide-react";
import React from "react";
interface FiltersButtonProps {
onClick: () => void;
active: boolean;
hasActiveFilters: boolean;
label?: string;
}
export const FiltersButton: React.FC<FiltersButtonProps> = ({
onClick,
active,
hasActiveFilters,
label = "Filters",
}) => {
return (
<Badge color="blue" dot={hasActiveFilters}>
<Button type="default" onClick={onClick} icon={<Filter size={16} />} className={active ? "bg-gray-100" : ""}>
{label}
</Button>
</Badge>
);
};
@@ -0,0 +1,29 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { ResetFiltersButton } from "./ResetFiltersButton";
describe("ResetFiltersButton", () => {
it("should render", () => {
const onClick = vi.fn();
render(<ResetFiltersButton onClick={onClick} />);
expect(screen.getByRole("button", { name: /reset filters/i })).toBeInTheDocument();
});
it("should call onClick when clicked", async () => {
const user = userEvent.setup();
const onClick = vi.fn();
render(<ResetFiltersButton onClick={onClick} />);
const button = screen.getByRole("button", { name: /reset filters/i });
await user.click(button);
expect(onClick).toHaveBeenCalledTimes(1);
});
it("should render custom label when provided", () => {
const onClick = vi.fn();
render(<ResetFiltersButton onClick={onClick} label="Clear All" />);
expect(screen.getByRole("button", { name: /clear all/i })).toBeInTheDocument();
});
});
@@ -0,0 +1,16 @@
import { Button } from "antd";
import { RotateCcw } from "lucide-react";
import React from "react";
interface ResetFiltersButtonProps {
onClick: () => void;
label?: string;
}
export const ResetFiltersButton: React.FC<ResetFiltersButtonProps> = ({ onClick, label = "Reset Filters" }) => {
return (
<Button type="default" onClick={onClick} icon={<RotateCcw size={16} />}>
{label}
</Button>
);
};