mirror of
https://github.com/tiennm99/litellm.git
synced 2026-07-12 15:05:01 +00:00
Organization Filters UI
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
+29
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user