Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useState } from "react";
import type { DateRange } from "react-day-picker";

import { AnnotationFilterInput } from "@/components/shared/AnnotationFilterInput/AnnotationFilterInput";
import { StatusFilterSelect } from "@/components/shared/StatusFilterSelect/StatusFilterSelect";
import { Button } from "@/components/ui/button";
import { DatePickerWithRange } from "@/components/ui/date-picker";
import { Icon } from "@/components/ui/icon";
Expand Down Expand Up @@ -72,6 +73,13 @@ export function PipelineRunFiltersBar() {
)}
</div>

<div className="shrink-0">
<StatusFilterSelect
value={filters.status}
onChange={(value) => setFilter("status", value)}
/>
</div>

<div className="shrink-0">
<DatePickerWithRange
value={dateRange}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";

import { StatusFilterSelect } from "./StatusFilterSelect";

describe("StatusFilterSelect", () => {
describe("rendering", () => {
it("should render with default placeholder when no value", () => {
render(<StatusFilterSelect value={undefined} onChange={vi.fn()} />);

expect(screen.getByRole("combobox")).toBeInTheDocument();
expect(screen.getByText("All statuses")).toBeInTheDocument();
});

it("should render with Running status", () => {
render(<StatusFilterSelect value="RUNNING" onChange={vi.fn()} />);

expect(screen.getByText("Running")).toBeInTheDocument();
});

it("should render with Succeeded status", () => {
render(<StatusFilterSelect value="SUCCEEDED" onChange={vi.fn()} />);

expect(screen.getByText("Succeeded")).toBeInTheDocument();
});

it("should render with Failed status", () => {
render(<StatusFilterSelect value="FAILED" onChange={vi.fn()} />);

expect(screen.getByText("Failed")).toBeInTheDocument();
});

it("should render with Pending status", () => {
render(<StatusFilterSelect value="PENDING" onChange={vi.fn()} />);

expect(screen.getByText("Pending")).toBeInTheDocument();
});

it("should render with Cancelled status", () => {
render(<StatusFilterSelect value="CANCELLED" onChange={vi.fn()} />);

expect(screen.getByText("Cancelled")).toBeInTheDocument();
});

it("should render with System error status", () => {
render(<StatusFilterSelect value="SYSTEM_ERROR" onChange={vi.fn()} />);

expect(screen.getByText("System error")).toBeInTheDocument();
});
});

describe("visual indicator", () => {
it("should show visual indicator when filter is active", () => {
const { container } = render(
<StatusFilterSelect value="FAILED" onChange={vi.fn()} />,
);

const trigger = container.querySelector("button");
expect(trigger).toHaveClass("ring-2");
});

it("should not show visual indicator when no filter", () => {
const { container } = render(
<StatusFilterSelect value={undefined} onChange={vi.fn()} />,
);

const trigger = container.querySelector("button");
expect(trigger).not.toHaveClass("ring-2");
});
});

describe("custom className", () => {
it("should apply custom className to trigger", () => {
const { container } = render(
<StatusFilterSelect
value={undefined}
onChange={vi.fn()}
className="custom-class"
/>,
);

const trigger = container.querySelector("button");
expect(trigger).toHaveClass("custom-class");
});
});
});
65 changes: 65 additions & 0 deletions src/components/shared/StatusFilterSelect/StatusFilterSelect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import type { ContainerExecutionStatus } from "@/api/types.gen";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import {
EXECUTION_STATUS_LABELS,
getExecutionStatusLabel,
} from "@/utils/executionStatus";

function isValidStatus(value: string): value is ContainerExecutionStatus {
return value in EXECUTION_STATUS_LABELS;
}

const STATUS_OPTIONS = Object.keys(EXECUTION_STATUS_LABELS).filter(
isValidStatus,
);

interface StatusFilterSelectProps {
value: ContainerExecutionStatus | undefined;
onChange: (value: ContainerExecutionStatus | undefined) => void;
className?: string;
}

export function StatusFilterSelect({
value,
onChange,
className,
}: StatusFilterSelectProps) {
const handleValueChange = (newValue: string) => {
if (newValue === "all") {
onChange(undefined);
} else if (isValidStatus(newValue)) {
onChange(newValue);
}
};

const hasActiveFilter = value !== undefined;

return (
<Select value={value ?? "all"} onValueChange={handleValueChange}>
<SelectTrigger
className={cn(
"w-40",
hasActiveFilter && "ring-2 ring-primary/20",
className,
)}
>
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
{STATUS_OPTIONS.map((status) => (
<SelectItem key={status} value={status}>
{getExecutionStatusLabel(status)}
</SelectItem>
))}
</SelectContent>
</Select>
);
}